mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 19:50:15 +00:00
Fix composer model picker opening lag
This commit is contained in:
committed by
nesquena-hermes
parent
73fe8f24c9
commit
53f294dc8d
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Composer model picker now opens immediately from the existing option list while the dynamic `/api/models` catalog hydrates in the background, and a just-selected session model survives a hard refresh even if the refresh interrupts the async session update. Previously a slow model-catalog request could make the model button appear unresponsive, and a quick reload after selecting a model could restore the old session model.
|
||||
|
||||
## [v0.51.112] — 2026-05-22 — Release CJ (stage-405 — 1-PR — session model authoritative across restore)
|
||||
|
||||
### Fixed
|
||||
|
||||
+11
-4
@@ -955,6 +955,11 @@ $('modelSelect').onchange=async()=>{
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
|
||||
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
|
||||
if(typeof _rememberPendingSessionModel==='function') _rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider);
|
||||
S.session.model=modelState.model;
|
||||
S.session.model_provider=modelState.model_provider||null;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
syncTopbar();
|
||||
// Clarify scope: composer model changes are session-local, not the global default.
|
||||
if(typeof showToast==='function'){
|
||||
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
|
||||
@@ -965,10 +970,12 @@ $('modelSelect').onchange=async()=>{
|
||||
model:modelState.model,
|
||||
model_provider:modelState.model_provider||null,
|
||||
})});
|
||||
S.session.model=modelState.model;
|
||||
S.session.model_provider=modelState.model_provider||null;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
syncTopbar();
|
||||
if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){
|
||||
const pending=_readPendingSessionModel(S.session.session_id);
|
||||
if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){
|
||||
_clearPendingSessionModel(S.session.session_id);
|
||||
}
|
||||
}
|
||||
_applySessionContextMetadataUpdate(data);
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
|
||||
@@ -622,6 +622,7 @@ async function loadSession(sid){
|
||||
if (typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
|
||||
try { window._resetScrollDirectionTracker(); } catch (_) {}
|
||||
}
|
||||
if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid);
|
||||
// Sync workspace display immediately so the chip label reflects the new session's workspace
|
||||
// before any async message-loading begins (mirrors how model is handled).
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
|
||||
+74
-7
@@ -733,6 +733,8 @@ window.addEventListener('visibilitychange',()=>{
|
||||
let _dynamicModelLabels={};
|
||||
window._configuredModelBadges=window._configuredModelBadges||{};
|
||||
const MODEL_STATE_KEY='hermes-webui-model-state';
|
||||
const PENDING_SESSION_MODEL_PREFIX='hermes-webui-pending-session-model:';
|
||||
const PENDING_SESSION_MODEL_MAX_AGE_MS=10*60*1000;
|
||||
|
||||
// ── Smart model resolver ────────────────────────────────────────────────────
|
||||
// Finds the best matching option value in a <select> for a given model ID.
|
||||
@@ -814,6 +816,71 @@ function _clearPersistedModelState(){
|
||||
localStorage.removeItem('hermes-webui-model');
|
||||
localStorage.removeItem(MODEL_STATE_KEY);
|
||||
}
|
||||
function _pendingSessionModelKey(sessionId){
|
||||
return PENDING_SESSION_MODEL_PREFIX+String(sessionId||'');
|
||||
}
|
||||
function _rememberPendingSessionModel(sessionId, model, modelProvider){
|
||||
const sid=String(sessionId||'').trim();
|
||||
const value=String(model||'').trim();
|
||||
if(!sid||!value) return;
|
||||
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
|
||||
try{
|
||||
sessionStorage.setItem(_pendingSessionModelKey(sid), JSON.stringify({
|
||||
model:value,
|
||||
model_provider:provider||null,
|
||||
saved_at:Date.now(),
|
||||
}));
|
||||
}catch(_){}
|
||||
}
|
||||
function _readPendingSessionModel(sessionId){
|
||||
const sid=String(sessionId||'').trim();
|
||||
if(!sid) return null;
|
||||
try{
|
||||
const raw=sessionStorage.getItem(_pendingSessionModelKey(sid));
|
||||
if(!raw) return null;
|
||||
const parsed=JSON.parse(raw);
|
||||
const model=String(parsed&&parsed.model||'').trim();
|
||||
if(!model){
|
||||
sessionStorage.removeItem(_pendingSessionModelKey(sid));
|
||||
return null;
|
||||
}
|
||||
const savedAt=Number(parsed.saved_at||0);
|
||||
if(savedAt&&Date.now()-savedAt>PENDING_SESSION_MODEL_MAX_AGE_MS){
|
||||
sessionStorage.removeItem(_pendingSessionModelKey(sid));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
model,
|
||||
model_provider:parsed&&parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(model)||null),
|
||||
};
|
||||
}catch(_){
|
||||
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(__){}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function _clearPendingSessionModel(sessionId){
|
||||
const sid=String(sessionId||'').trim();
|
||||
if(!sid) return;
|
||||
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(_){}
|
||||
}
|
||||
function _applyPendingSessionModelForSession(sessionId){
|
||||
if(!S.session||S.session.session_id!==sessionId) return false;
|
||||
const pending=_readPendingSessionModel(sessionId);
|
||||
if(!pending) return false;
|
||||
const sameModel=String(S.session.model||'')===pending.model;
|
||||
const sameProvider=String(S.session.model_provider||'')===String(pending.model_provider||'');
|
||||
if(sameModel&&sameProvider){
|
||||
_clearPendingSessionModel(sessionId);
|
||||
return false;
|
||||
}
|
||||
S.session.model=pending.model;
|
||||
S.session.model_provider=pending.model_provider||null;
|
||||
const retry=_persistSessionModelCorrection(pending.model,pending.model_provider||null,{propagateErrors:true});
|
||||
if(retry&&typeof retry.then==='function'){
|
||||
retry.then(()=>_clearPendingSessionModel(sessionId)).catch(()=>{});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function _findModelInDropdown(modelId, sel, preferredProviderId){
|
||||
if(!modelId||!sel) return null;
|
||||
const options=Array.from(sel.options);
|
||||
@@ -893,13 +960,14 @@ function _modelStateFromAppliedDropdown(sel, modelValue){
|
||||
: {model:modelValue,model_provider:null};
|
||||
return {model:state.model||modelValue,model_provider:state.model_provider||null};
|
||||
}
|
||||
function _persistSessionModelCorrection(model, provider){
|
||||
function _persistSessionModelCorrection(model, provider, opts){
|
||||
if(!S.session) return;
|
||||
fetch(new URL('api/session/update',document.baseURI||location.href).href,{
|
||||
const request=fetch(new URL('api/session/update',document.baseURI||location.href).href,{
|
||||
method:'POST',credentials:'include',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
|
||||
}).catch(()=>{});
|
||||
});
|
||||
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
|
||||
}
|
||||
function _applySessionModelFallback(sel){
|
||||
if(!sel) return null;
|
||||
@@ -1496,10 +1564,9 @@ async function toggleModelDropdown(){
|
||||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
|
||||
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
|
||||
if(typeof window._ensureModelDropdownReady==='function') window._ensureModelDropdownReady();
|
||||
const ready=window._modelDropdownReady;
|
||||
if(ready&&typeof ready.then==='function'){
|
||||
try{await ready;}catch(_){}
|
||||
if(typeof window._ensureModelDropdownReady==='function'){
|
||||
const ready=window._ensureModelDropdownReady();
|
||||
if(ready&&typeof ready.catch==='function') ready.catch(()=>{});
|
||||
}
|
||||
if(dd.classList.contains('open')) return;
|
||||
renderModelDropdown();
|
||||
|
||||
@@ -12,14 +12,16 @@ def _body_between(src: str, start: str, end: str) -> str:
|
||||
return src[start_idx:end_idx]
|
||||
|
||||
|
||||
def test_model_picker_open_waits_for_async_model_catalog_before_rendering():
|
||||
"""Opening the visible picker must not render stale static <select> options."""
|
||||
def test_model_picker_opens_before_async_model_catalog_finishes():
|
||||
"""Opening the visible picker must not block on a slow /api/models request."""
|
||||
body = _body_between(UI_JS, "async function toggleModelDropdown", "function closeModelDropdown")
|
||||
|
||||
assert "window._modelDropdownReady" in body
|
||||
assert "window._ensureModelDropdownReady" in body
|
||||
assert "await" in body
|
||||
assert body.index("await") < body.index("renderModelDropdown()")
|
||||
render_idx = body.index("renderModelDropdown()")
|
||||
open_idx = body.index("dd.classList.add('open')")
|
||||
await_idx = body.find("await")
|
||||
assert render_idx < open_idx
|
||||
assert await_idx == -1 or open_idx < await_idx
|
||||
|
||||
|
||||
def test_populate_model_dropdown_rerenders_if_picker_is_already_open():
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Regression coverage for model selection surviving hard refresh.
|
||||
|
||||
The frontend updates the visible model chip before the async
|
||||
``/api/session/update`` request returns. A hard refresh can abort that request,
|
||||
so the browser must remember the session-scoped selection and reapply it on the
|
||||
next ``loadSession()`` before ``syncTopbar()`` projects server metadata.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text()
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text()
|
||||
|
||||
|
||||
def _body_between(src: str, start: str, end: str) -> str:
|
||||
start_idx = src.index(start)
|
||||
end_idx = src.index(end, start_idx)
|
||||
return src[start_idx:end_idx]
|
||||
|
||||
|
||||
def test_model_selection_records_pending_state_before_async_session_update():
|
||||
"""A refresh during /api/session/update must not lose the selected model."""
|
||||
body = _body_between(BOOT_JS, "$('modelSelect').onchange=async()=>", "$('msg').addEventListener")
|
||||
|
||||
pending_idx = body.index("_rememberPendingSessionModel")
|
||||
local_model_idx = body.index("S.session.model=modelState.model")
|
||||
update_idx = body.index("await api('/api/session/update'")
|
||||
|
||||
assert pending_idx < update_idx
|
||||
assert local_model_idx < update_idx
|
||||
assert "_clearPendingSessionModel" in body
|
||||
|
||||
|
||||
def test_load_session_applies_pending_model_before_first_topbar_sync():
|
||||
"""Reload should project the pending selection before server old metadata wins."""
|
||||
body = _body_between(SESSIONS_JS, "async function loadSession", "const activeStreamId=")
|
||||
|
||||
apply_idx = body.index("_applyPendingSessionModelForSession")
|
||||
sync_idx = body.index("syncTopbar()")
|
||||
|
||||
assert apply_idx < sync_idx
|
||||
|
||||
|
||||
def test_pending_model_helpers_are_session_scoped_and_expire():
|
||||
assert "const PENDING_SESSION_MODEL_PREFIX" in UI_JS
|
||||
assert "function _pendingSessionModelKey" in UI_JS
|
||||
assert "function _rememberPendingSessionModel" in UI_JS
|
||||
assert "function _applyPendingSessionModelForSession" in UI_JS
|
||||
assert "propagateErrors:true" in UI_JS
|
||||
assert "sessionStorage" in UI_JS
|
||||
assert "10*60*1000" in UI_JS
|
||||
Reference in New Issue
Block a user