mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
45fe6294f9
The boot IIFE unconditionally overwrote localStorage with whatever settings.json had on the server. If the appearance autosave POST ever failed (network glitch, transient error) the next page load would revert the user's chosen theme/skin to the server's stale defaults. Fix: reconcile localStorage against the server on boot. When localStorage carries a non-default skin or system theme (the user explicitly chose something), localStorage wins and the fix pushes those values back to the server. When localStorage is at defaults (new browser / first visit), the server still wins. Tested scenarios: - User chose non-default skin, autosave failed → preserved + reconciled - New browser, server has non-default skin → server value applied - Normal use (autosave works) → unchanged behavior
1638 lines
67 KiB
JavaScript
1638 lines
67 KiB
JavaScript
async function cancelStream(){
|
|
const streamId = S.activeStreamId;
|
|
if(!streamId) return;
|
|
try{
|
|
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
|
|
}catch(e){/* cancel request failed - cleanup below still runs */}
|
|
// Clear status unconditionally after the cancel request completes.
|
|
// The SSE cancel event may also fire, but if the connection is already
|
|
// closed it won't arrive — so we handle cleanup here as the guaranteed path.
|
|
S.activeStreamId=null;
|
|
setBusy(false);
|
|
if(typeof setComposerStatus==='function') setComposerStatus('');
|
|
else setStatus('');
|
|
}
|
|
|
|
async function cancelSessionStream(session){
|
|
const streamId = session&&session.active_stream_id;
|
|
const sid = session&&session.session_id;
|
|
if(!streamId||!sid) return;
|
|
try{
|
|
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
|
|
}catch(e){/* cancel request failed - cleanup below still runs */}
|
|
session.active_stream_id=null;
|
|
delete INFLIGHT[sid];
|
|
clearInflightState(sid);
|
|
if(S.session&&S.session.session_id===sid){
|
|
S.activeStreamId=null;
|
|
if(S.session) S.session.active_stream_id=null;
|
|
clearInflight();
|
|
setBusy(false);
|
|
if(typeof setComposerStatus==='function') setComposerStatus('');
|
|
else setStatus('');
|
|
}
|
|
if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
|
|
stopApprovalPolling();
|
|
hideApprovalCard(true);
|
|
}
|
|
if(typeof _clarifySessionId!=='undefined' && _clarifySessionId===sid){
|
|
stopClarifyPolling();
|
|
hideClarifyCard(true, 'cancelled');
|
|
}
|
|
if(typeof renderSessionList==='function') renderSessionList();
|
|
}
|
|
|
|
async function _savedSessionShouldStaySidebarOnly(sid){
|
|
if(!sid) return false;
|
|
try{
|
|
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
|
|
const session = data&&data.session;
|
|
return !!(session&&(session.active_stream_id||session.pending_user_message));
|
|
}catch(e){
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Mobile navigation ──────────────────────────────────────────────────────
|
|
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
|
|
|
|
function _isCompactWorkspaceViewport(){
|
|
return window.matchMedia('(max-width: 900px)').matches;
|
|
}
|
|
|
|
function _syncWorkspacePanelInlineWidth(){
|
|
const {panel}= _workspacePanelEls();
|
|
if(!panel) return;
|
|
|
|
const isCompact = _isCompactWorkspaceViewport();
|
|
if(isCompact){
|
|
if(panel.style.width) panel.style.removeProperty('width');
|
|
return;
|
|
}
|
|
|
|
const saved = localStorage.getItem('hermes-panel-w');
|
|
if(!saved) return;
|
|
const parsed = parseInt(saved, 10);
|
|
if(Number.isNaN(parsed) || parsed <= 0) return;
|
|
panel.style.width = `${parsed}px`;
|
|
}
|
|
|
|
function _workspacePanelEls(){
|
|
return {
|
|
layout: document.querySelector('.layout'),
|
|
panel: document.querySelector('.rightpanel'),
|
|
toggleBtn: $('btnWorkspacePanelToggle'),
|
|
collapseBtn: $('btnCollapseWorkspacePanel'),
|
|
};
|
|
}
|
|
|
|
function _hasWorkspacePreviewVisible(){
|
|
const preview=$('previewArea');
|
|
return !!(preview&&preview.classList.contains('visible'));
|
|
}
|
|
|
|
function _setWorkspacePanelMode(mode){
|
|
const {layout,panel}= _workspacePanelEls();
|
|
if(!layout||!panel)return;
|
|
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
|
|
const open=_workspacePanelMode!=='closed';
|
|
document.documentElement.dataset.workspacePanel=open?'open':'closed';
|
|
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
|
|
// Do NOT overwrite the user's "keep open" preference — only track runtime state
|
|
// so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting.
|
|
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
|
|
layout.classList.toggle('workspace-panel-collapsed',!open);
|
|
if(_isCompactWorkspaceViewport()){
|
|
panel.classList.toggle('mobile-open',open);
|
|
}else{
|
|
panel.classList.remove('mobile-open');
|
|
}
|
|
syncWorkspacePanelUI();
|
|
}
|
|
|
|
function syncWorkspacePanelState(){
|
|
const hasPreview=_hasWorkspacePreviewVisible();
|
|
if(hasPreview){
|
|
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
|
|
else syncWorkspacePanelUI();
|
|
return;
|
|
}
|
|
if(!S.session){
|
|
// No active session — if the panel was explicitly opened (browse mode), keep it
|
|
// open so the workspace pane doesn't vanish on a fresh-page or empty-session boot.
|
|
// The file tree will show the "no workspace" placeholder naturally via renderFileTree().
|
|
// Only force-close if the mode is 'preview' (file preview without a session is invalid).
|
|
if(_workspacePanelMode==='preview') _setWorkspacePanelMode('closed');
|
|
else syncWorkspacePanelUI();
|
|
return;
|
|
}
|
|
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
|
|
}
|
|
|
|
function openWorkspacePanel(mode='browse'){
|
|
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible()&&!S._profileDefaultWorkspace)return;
|
|
if(mode==='preview'&&_workspacePanelMode==='browse'){
|
|
syncWorkspacePanelUI();
|
|
return;
|
|
}
|
|
_setWorkspacePanelMode(mode);
|
|
}
|
|
|
|
function closeWorkspacePanel(){
|
|
_setWorkspacePanelMode('closed');
|
|
}
|
|
|
|
function ensureWorkspacePreviewVisible(){
|
|
if(_workspacePanelMode==='closed') _setWorkspacePanelMode('preview');
|
|
else syncWorkspacePanelUI();
|
|
}
|
|
|
|
function handleWorkspaceClose(){
|
|
if(_hasWorkspacePreviewVisible()){
|
|
clearPreview();
|
|
return;
|
|
}
|
|
closeWorkspacePanel();
|
|
}
|
|
|
|
/**
|
|
* Set a tooltip on a button, preferring the custom CSS tooltip (`data-tooltip`)
|
|
* when the element opts in via the `has-tooltip` class. Falls back to the
|
|
* native `title` attribute for elements that haven't opted in.
|
|
*
|
|
* Critical: when the element DOES have data-tooltip, this MUST also clear any
|
|
* existing native `title` attribute, otherwise the slow ~1.5s native browser
|
|
* tooltip co-fires alongside the fast custom CSS tooltip — exactly the bug
|
|
* #1775 reports. Always pair `data-tooltip` with `removeAttribute('title')`.
|
|
*/
|
|
function _setButtonTooltip(btn, text){
|
|
if(!btn) return;
|
|
if(btn.hasAttribute('data-tooltip')){
|
|
btn.setAttribute('data-tooltip', text);
|
|
if(btn.hasAttribute('title')) btn.removeAttribute('title');
|
|
} else {
|
|
btn.title = text;
|
|
}
|
|
}
|
|
|
|
function syncWorkspacePanelUI(){
|
|
const {layout,panel,toggleBtn,collapseBtn}= _workspacePanelEls();
|
|
if(!layout||!panel)return;
|
|
const desktopOpen=_workspacePanelMode!=='closed';
|
|
const mobileOpen=panel.classList.contains('mobile-open');
|
|
const isCompact=_isCompactWorkspaceViewport();
|
|
const isOpen=isCompact?mobileOpen:desktopOpen;
|
|
const canBrowse=!!S.session||_hasWorkspacePreviewVisible()||!!(S._profileDefaultWorkspace);
|
|
const hasPreview=_hasWorkspacePreviewVisible();
|
|
if(toggleBtn){
|
|
toggleBtn.classList.toggle('active',isOpen);
|
|
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
|
|
_setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
|
|
toggleBtn.disabled=!canBrowse;
|
|
}
|
|
if(collapseBtn){
|
|
_setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel');
|
|
}
|
|
const hasSession=!!S.session;
|
|
['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
|
|
const el=$(id);
|
|
if(el)el.disabled=!hasSession;
|
|
});
|
|
const clearBtn=$('btnClearPreview');
|
|
if(clearBtn){
|
|
clearBtn.disabled=!isOpen;
|
|
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Hide workspace panel');
|
|
// On desktop, only show the X button when a file preview is open.
|
|
// In browse mode the chevron (btnCollapseWorkspacePanel) already serves
|
|
// as the close control, so showing both produces a duplicate X.
|
|
if(!isCompact) clearBtn.style.display=hasPreview?'':'none';
|
|
}
|
|
}
|
|
|
|
function toggleMobileSidebar(){
|
|
const sidebar=document.querySelector('.sidebar');
|
|
const overlay=$('mobileOverlay');
|
|
if(!sidebar)return;
|
|
const isOpen=sidebar.classList.contains('mobile-open');
|
|
if(isOpen){closeMobileSidebar();}
|
|
else{sidebar.classList.add('mobile-open');if(overlay)overlay.classList.add('visible');}
|
|
}
|
|
function closeMobileSidebar(){
|
|
const sidebar=document.querySelector('.sidebar');
|
|
const overlay=$('mobileOverlay');
|
|
if(sidebar)sidebar.classList.remove('mobile-open');
|
|
if(overlay)overlay.classList.remove('visible');
|
|
}
|
|
|
|
// ── Desktop sidebar collapse toggle ────────────────────────────────────────
|
|
// Two discoverability paths into the same state:
|
|
// (1) Click the already-active rail icon → collapse / expand the sidebar.
|
|
// (2) Cmd/Ctrl+B keyboard shortcut (VS Code convention).
|
|
// Mobile is unaffected: the sidebar is an overlay there, and every collapse
|
|
// code path is gated on `_isDesktopWidth()` (min-width:641px).
|
|
// State is persisted via localStorage and survives reloads + bfcache.
|
|
const _SIDEBAR_COLLAPSED_KEY='hermes-webui-sidebar-collapsed';
|
|
|
|
function _isDesktopWidth(){
|
|
try{return window.matchMedia('(min-width:641px)').matches;}catch(_){return true;}
|
|
}
|
|
|
|
function _isSidebarCollapsed(){
|
|
return document.querySelector('.layout')?.classList.contains('sidebar-collapsed')||false;
|
|
}
|
|
|
|
function _syncSidebarAria(){
|
|
// Mirror the open/collapsed state on the active rail button via aria-expanded
|
|
// so screen readers announce the toggle. Open=true, collapsed=false.
|
|
const active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
|
|
if(active)active.setAttribute('aria-expanded',!_isSidebarCollapsed());
|
|
}
|
|
|
|
function toggleSidebar(forceState){
|
|
if(!_isDesktopWidth())return; // mobile uses an overlay; never collapse there
|
|
const layout=document.querySelector('.layout');
|
|
if(!layout)return;
|
|
const next=typeof forceState==='boolean'?forceState:!_isSidebarCollapsed();
|
|
layout.classList.toggle('sidebar-collapsed',next);
|
|
// Clear the flash-prevention root-level marker once JS owns the state.
|
|
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
|
|
try{localStorage.setItem(_SIDEBAR_COLLAPSED_KEY,next?'1':'0');}catch(_){}
|
|
_syncSidebarAria();
|
|
}
|
|
|
|
function expandSidebar(){
|
|
if(_isSidebarCollapsed())toggleSidebar(false);
|
|
}
|
|
|
|
// Boot-time restore. The inline flash-prevention script in index.html already
|
|
// set data-sidebar-collapsed='1' on <html> before the stylesheet so the page
|
|
// renders collapsed without paint flash. This IIFE promotes that pre-paint
|
|
// state into the .layout class system where both JS and CSS can read it.
|
|
(function _restoreSidebarState(){
|
|
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
|
|
if(!_isDesktopWidth())return;
|
|
try{
|
|
if(localStorage.getItem(_SIDEBAR_COLLAPSED_KEY)==='1'){
|
|
const layout=document.querySelector('.layout');
|
|
if(layout)layout.classList.add('sidebar-collapsed');
|
|
}
|
|
}catch(_){}
|
|
_syncSidebarAria();
|
|
})();
|
|
function toggleMobileFiles(){
|
|
toggleWorkspacePanel();
|
|
}
|
|
function toggleWorkspacePanel(force){
|
|
const {panel}= _workspacePanelEls();
|
|
if(!panel)return;
|
|
const currentlyOpen=_workspacePanelMode!=='closed';
|
|
const nextOpen=typeof force==='boolean'?force:!currentlyOpen;
|
|
if(!nextOpen){
|
|
closeWorkspacePanel();
|
|
return;
|
|
}
|
|
const nextMode=_hasWorkspacePreviewVisible()?'preview':'browse';
|
|
openWorkspacePanel(nextMode);
|
|
}
|
|
function mobileSwitchPanel(name){
|
|
switchPanel(name);
|
|
if(name==='chat'){
|
|
closeMobileSidebar();
|
|
} else {
|
|
const sidebar=document.querySelector('.sidebar');
|
|
const overlay=$('mobileOverlay');
|
|
if(sidebar){
|
|
sidebar.classList.add('mobile-open');
|
|
if(overlay)overlay.classList.add('visible');
|
|
}
|
|
}
|
|
}
|
|
|
|
$('btnSend').onclick=()=>{
|
|
if(typeof handleComposerPrimaryAction==='function') return handleComposerPrimaryAction();
|
|
if(window._micActive){
|
|
window._micPendingSend=true;
|
|
_stopMic();
|
|
return;
|
|
}
|
|
// Turn-based voice mode: let the voice mode system handle the send flow
|
|
if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){
|
|
// Immediately send whatever is in the textarea
|
|
if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend();
|
|
return;
|
|
}
|
|
send();
|
|
};
|
|
$('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();};
|
|
|
|
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
|
|
(function(){
|
|
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
|
const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder);
|
|
if(!SpeechRecognition&&!_canRecordAudio) return; // Browser unsupported — mic button stays hidden
|
|
|
|
// Persist SR failure across reloads (e.g. Tailscale/network error)
|
|
const _micForceMediaRecorderKey='mic_force_mediarecorder';
|
|
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
|
|
|
|
const btn=$('btnMic');
|
|
const status=$('micStatus');
|
|
const ta=$('msg');
|
|
const statusText=status?status.querySelector('.status-text'):null;
|
|
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
|
|
|
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
|
|
let mediaRecorder=null;
|
|
let mediaStream=null;
|
|
let audioChunks=[];
|
|
let _finalText='';
|
|
let _prefix='';
|
|
let _isRecording=false;
|
|
|
|
function _setRecording(on){
|
|
window._micActive=on;
|
|
btn.classList.toggle('recording',on);
|
|
// Active-state title flips so the tooltip is honest about what
|
|
// pressing the button will do (#1488).
|
|
_setButtonTooltip(btn, on ? t('voice_dictate_active') : t('voice_dictate'));
|
|
status.style.display=on?'':'none';
|
|
if(statusText) statusText.textContent=on?'Listening':'Listening';
|
|
if(!on){ _finalText=''; _prefix=''; }
|
|
}
|
|
|
|
function _commitTranscript(text){
|
|
const clean=(text||'').trim();
|
|
const committed=clean
|
|
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
|
? _prefix+' '+clean.trimStart()
|
|
: _prefix+clean)
|
|
: ta.value;
|
|
ta.value=committed;
|
|
autoResize();
|
|
if(window._micPendingSend){
|
|
window._micPendingSend=false;
|
|
send();
|
|
}
|
|
}
|
|
|
|
async function _transcribeBlob(blob){
|
|
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
|
|
const form=new FormData();
|
|
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
|
|
setComposerStatus('Transcribing…');
|
|
try{
|
|
const res=await fetch('api/transcribe',{method:'POST',body:form});
|
|
const data=await res.json().catch(()=>({}));
|
|
if(!res.ok) throw new Error(data.error||'Transcription failed');
|
|
_commitTranscript(data.transcript||'');
|
|
}catch(err){
|
|
window._micPendingSend=false;
|
|
showToast(err.message||t('mic_network'));
|
|
}finally{
|
|
setComposerStatus('');
|
|
}
|
|
}
|
|
|
|
function _stopTracks(){
|
|
if(mediaStream){
|
|
mediaStream.getTracks().forEach(track=>track.stop());
|
|
mediaStream=null;
|
|
}
|
|
}
|
|
|
|
function _stopMic(){
|
|
if(!window._micActive) return;
|
|
if(recognition){
|
|
recognition.stop();
|
|
return;
|
|
}
|
|
if(mediaRecorder&&mediaRecorder.state!=='inactive'){
|
|
mediaRecorder.stop();
|
|
return;
|
|
}
|
|
_setRecording(false);
|
|
_stopTracks();
|
|
}
|
|
window._stopMic=_stopMic; // expose for send-guard above
|
|
|
|
if(recognition && !_forceMediaRecorder){
|
|
recognition.continuous=false;
|
|
recognition.interimResults=true;
|
|
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
|
|
|
recognition.onstart=()=>{ _finalText=''; };
|
|
|
|
recognition.onresult=(event)=>{
|
|
let interim='';
|
|
let final=_finalText;
|
|
for(let i=event.resultIndex;i<event.results.length;i++){
|
|
const t=event.results[i][0].transcript;
|
|
if(event.results[i].isFinal){ final+=t; _finalText=final; }
|
|
else{ interim+=t; }
|
|
}
|
|
ta.value=_prefix+(final||interim);
|
|
autoResize();
|
|
};
|
|
|
|
recognition.onend=()=>{
|
|
const committed=_finalText
|
|
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
|
? _prefix+' '+_finalText.trimStart()
|
|
: _prefix+_finalText)
|
|
: ta.value;
|
|
_setRecording(false);
|
|
ta.value=committed;
|
|
autoResize();
|
|
if(window._micPendingSend){
|
|
window._micPendingSend=false;
|
|
send();
|
|
}
|
|
};
|
|
|
|
recognition.onerror=(event)=>{
|
|
_setRecording(false);
|
|
window._micPendingSend=false;
|
|
_isRecording=false;
|
|
if(event.error==='network'||event.error==='not-allowed'){
|
|
// Persist SR failure: next reload will skip SpeechRecognition
|
|
localStorage.setItem(_micForceMediaRecorderKey,'1');
|
|
_forceMediaRecorder=true;
|
|
recognition=null;
|
|
}
|
|
const msgs={
|
|
'not-allowed':t('mic_denied'),
|
|
'no-speech':t('mic_no_speech'),
|
|
'network':t('mic_network'),
|
|
};
|
|
showToast(msgs[event.error]||t('mic_error')+event.error);
|
|
};
|
|
}
|
|
|
|
btn.onclick=async()=>{
|
|
// Race-condition guard: ignore rapid double-clicks
|
|
if(_isRecording){
|
|
_stopMic();
|
|
_isRecording=false;
|
|
return;
|
|
}
|
|
if(window._micActive){
|
|
_stopMic();
|
|
return;
|
|
}
|
|
_isRecording=true;
|
|
_finalText='';
|
|
_prefix=ta.value;
|
|
if(recognition && !_forceMediaRecorder){
|
|
recognition.start();
|
|
_setRecording(true);
|
|
return;
|
|
}
|
|
if(!_canRecordAudio){
|
|
_isRecording=false;
|
|
showToast(t('mic_network'));
|
|
return;
|
|
}
|
|
try{
|
|
mediaStream=await navigator.mediaDevices.getUserMedia({audio:true});
|
|
const preferredTypes=['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/ogg'];
|
|
const mimeType=preferredTypes.find(type=>window.MediaRecorder.isTypeSupported?.(type))||'';
|
|
mediaRecorder=new MediaRecorder(mediaStream,mimeType?{mimeType}:undefined);
|
|
audioChunks=[];
|
|
mediaRecorder.ondataavailable=e=>{if(e.data&&e.data.size)audioChunks.push(e.data);};
|
|
mediaRecorder.onerror=()=>{
|
|
_isRecording=false;
|
|
_setRecording(false);
|
|
window._micPendingSend=false;
|
|
_stopTracks();
|
|
showToast(t('mic_network'));
|
|
};
|
|
mediaRecorder.onstop=async()=>{
|
|
_isRecording=false;
|
|
const blob=new Blob(audioChunks,{type:mediaRecorder.mimeType||mimeType||'audio/webm'});
|
|
_setRecording(false);
|
|
_stopTracks();
|
|
if(blob.size){ await _transcribeBlob(blob); }
|
|
else if(window._micPendingSend){
|
|
window._micPendingSend=false;
|
|
}
|
|
};
|
|
mediaRecorder.start();
|
|
_setRecording(true);
|
|
}catch(err){
|
|
_isRecording=false;
|
|
window._micPendingSend=false;
|
|
_stopTracks();
|
|
showToast(t('mic_denied'));
|
|
}
|
|
};
|
|
})();
|
|
window._micActive=window._micActive||false;
|
|
window._micPendingSend=window._micPendingSend||false;
|
|
|
|
// ── Turn-based voice mode (#1333) ────────────────────────────────────────
|
|
// Chained flow: listen → send → (agent processes) → TTS response → listen again
|
|
(function(){
|
|
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
|
const hasSTT=!(!SpeechRecognition);
|
|
const hasTTS=!!('speechSynthesis' in window);
|
|
|
|
// Need both STT and TTS for turn-based voice mode
|
|
if(!hasSTT||!hasTTS) return;
|
|
|
|
const modeBtn=$('btnVoiceMode');
|
|
const bar=$('voiceModeBar');
|
|
const indicator=$('voiceModeIndicator');
|
|
const label=$('voiceModeLabel');
|
|
const micBtn=$('btnMic');
|
|
const ta=$('msg');
|
|
|
|
if(!modeBtn||!bar||!indicator||!label) return;
|
|
|
|
// Voice-mode button is gated behind a Preferences toggle (#1488).
|
|
// Default off — keeps the composer footer uncluttered for users who
|
|
// only need plain dictation. The hands-free conversation feature is
|
|
// a power-user surface; explicit opt-in avoids the visual confusion
|
|
// of two near-identical mic icons.
|
|
function _voiceModePrefEnabled(){
|
|
try{ return localStorage.getItem('hermes-voice-mode-button')==='true'; }
|
|
catch(_){ return false; }
|
|
}
|
|
let _voiceModeActive=false;
|
|
|
|
function _applyVoiceModePref(){
|
|
const enabled = _voiceModePrefEnabled();
|
|
modeBtn.style.display = enabled ? '' : 'none';
|
|
if(!enabled && _voiceModeActive) _deactivate();
|
|
}
|
|
_applyVoiceModePref();
|
|
// Expose so the settings pane can re-apply immediately on toggle.
|
|
window._applyVoiceModePref = _applyVoiceModePref;
|
|
|
|
let _voiceModeState='idle'; // idle | listening | thinking | speaking
|
|
let _recognition=null;
|
|
let _silenceTimer=null;
|
|
// Capture the session id at thinking-time so the TTS callback won't read
|
|
// a different session's last assistant reply if the user navigated away
|
|
// between send and stream completion. (Opus pre-release advisor.)
|
|
let _voiceModeThinkingSid=null;
|
|
const SILENCE_MS=1800; // auto-send after 1.8s silence
|
|
|
|
function _setState(state){
|
|
_voiceModeState=state;
|
|
indicator.className='voice-mode-indicator '+state;
|
|
label.textContent=state==='listening'?t('voice_listening')
|
|
:state==='speaking'?t('voice_speaking')
|
|
:state==='thinking'?t('voice_thinking')
|
|
:'';
|
|
bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none';
|
|
}
|
|
|
|
function _startListening(){
|
|
if(!_voiceModeActive) return;
|
|
_setState('listening');
|
|
|
|
_recognition=new SpeechRecognition();
|
|
_recognition.continuous=false;
|
|
_recognition.interimResults=true;
|
|
_recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
|
|
|
let _finalText='';
|
|
|
|
_recognition.onstart=()=>{ _finalText=''; };
|
|
|
|
_recognition.onresult=(event)=>{
|
|
// Reset silence timer on any result
|
|
clearTimeout(_silenceTimer);
|
|
let interim='';
|
|
let final=_finalText;
|
|
for(let i=event.resultIndex;i<event.results.length;i++){
|
|
const txt=event.results[i][0].transcript;
|
|
if(event.results[i].isFinal){ final+=txt; _finalText=final; }
|
|
else{ interim+=txt; }
|
|
}
|
|
ta.value=final||interim;
|
|
autoResize();
|
|
|
|
// Auto-send on silence after final result
|
|
if(_finalText){
|
|
_silenceTimer=setTimeout(()=>{
|
|
_voiceModeSend();
|
|
},SILENCE_MS);
|
|
}
|
|
};
|
|
|
|
_recognition.onend=()=>{
|
|
clearTimeout(_silenceTimer);
|
|
// If we have text and haven't sent yet, send it
|
|
if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){
|
|
_voiceModeSend();
|
|
} else if(_voiceModeActive&&_voiceModeState==='listening'){
|
|
// No speech detected — restart listening
|
|
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500);
|
|
}
|
|
};
|
|
|
|
_recognition.onerror=(event)=>{
|
|
clearTimeout(_silenceTimer);
|
|
if(event.error==='no-speech'||event.error==='aborted'){
|
|
// Restart if still active
|
|
if(_voiceModeActive){
|
|
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800);
|
|
}
|
|
return;
|
|
}
|
|
if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){
|
|
_deactivate();
|
|
showToast(t('mic_denied'));
|
|
return;
|
|
}
|
|
// Other errors — try to restart
|
|
if(_voiceModeActive){
|
|
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500);
|
|
}
|
|
};
|
|
|
|
try{ _recognition.start(); }catch(e){
|
|
// Already started or other error — retry shortly
|
|
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000);
|
|
}
|
|
}
|
|
|
|
function _voiceModeSend(){
|
|
if(!_voiceModeActive) return;
|
|
const text=(ta.value||'').trim();
|
|
if(!text){
|
|
ta.value='';
|
|
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300);
|
|
return;
|
|
}
|
|
_setState('thinking');
|
|
// Pin the active session id so the TTS callback won't speak a different
|
|
// session's reply if the user navigates away mid-stream.
|
|
_voiceModeThinkingSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
|
try{ if(_recognition) _recognition.abort(); }catch(_){}
|
|
_recognition=null;
|
|
// send() is global from boot.js
|
|
if(typeof send==='function') send();
|
|
}
|
|
|
|
function _speakResponse(){
|
|
if(!_voiceModeActive) return;
|
|
// Bail out if the user navigated to a different session between send and
|
|
// stream completion. The patched autoReadLastAssistant fires globally;
|
|
// without this guard it would TTS-read the wrong session's last assistant
|
|
// message. Drop back to listening on the new session instead.
|
|
const currentSid=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
|
if(_voiceModeThinkingSid && currentSid && currentSid!==_voiceModeThinkingSid){
|
|
_voiceModeThinkingSid=null;
|
|
_startListening();
|
|
return;
|
|
}
|
|
_voiceModeThinkingSid=null;
|
|
_setState('speaking');
|
|
|
|
// Find last assistant message
|
|
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
|
if(!rows.length){ _startListening(); return; }
|
|
const last=rows[rows.length-1];
|
|
const rawText=last.dataset.rawText||'';
|
|
if(!rawText.trim()){ _startListening(); return; }
|
|
|
|
// Strip for TTS (reuse existing helper if available)
|
|
let clean=rawText;
|
|
if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText);
|
|
else{
|
|
// Basic strip: remove code blocks, images, links
|
|
clean=clean.replace(/```[\s\S]*?```/g,' code block ')
|
|
.replace(/`([^`]*)`/g,'$1')
|
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1')
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g,'$1')
|
|
.replace(/#{1,6}\s/g,'')
|
|
.replace(/[*_~]+/g,'')
|
|
.replace(/\n{2,}/g,'. ')
|
|
.replace(/\n/g,' ')
|
|
.trim();
|
|
}
|
|
if(!clean){ _startListening(); return; }
|
|
|
|
const utter=new SpeechSynthesisUtterance(clean);
|
|
|
|
// Apply saved voice preferences
|
|
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
|
const voices=speechSynthesis.getVoices();
|
|
if(savedVoice&&voices.length){
|
|
const match=voices.find(v=>v.name===savedVoice);
|
|
if(match) utter.voice=match;
|
|
}
|
|
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
|
|
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
|
|
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
|
|
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
|
|
|
|
utter.onend=()=>{
|
|
// After speaking, go back to listening
|
|
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
|
|
};
|
|
utter.onerror=()=>{
|
|
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
|
|
};
|
|
|
|
speechSynthesis.speak(utter);
|
|
}
|
|
|
|
// Hook into response completion — observe when the agent finishes
|
|
// We patch setComposerStatus to detect when a response completes
|
|
const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null;
|
|
|
|
window._voiceModeOnResponseComplete=function(){
|
|
if(_voiceModeActive&&_voiceModeState==='thinking'){
|
|
// Small delay to let DOM render the final message
|
|
setTimeout(()=>{
|
|
if(_voiceModeActive&&_voiceModeState==='thinking'){
|
|
_speakResponse();
|
|
}
|
|
},400);
|
|
}
|
|
};
|
|
|
|
// Observe S.busy changes to detect response completion
|
|
// The existing code calls setBusy(false) when response completes
|
|
const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null;
|
|
if(_origSetBusy){
|
|
// We use a MutationObserver-style approach via polling S.busy
|
|
// Actually, we'll use a simpler approach: hook into the message stream completion
|
|
}
|
|
|
|
// Most reliable hook: use the existing autoReadLastAssistant call site.
|
|
// We override autoReadLastAssistant so that if voice mode is active, we use our
|
|
// own speak-and-resume flow instead of the default auto-read.
|
|
const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null;
|
|
window.autoReadLastAssistant=function(){
|
|
if(_voiceModeActive&&_voiceModeState==='thinking'){
|
|
_speakResponse();
|
|
return;
|
|
}
|
|
if(_origAutoRead) _origAutoRead.apply(this,arguments);
|
|
};
|
|
|
|
function _activate(){
|
|
_voiceModeActive=true;
|
|
modeBtn.classList.add('active');
|
|
_setButtonTooltip(modeBtn, t('voice_mode_toggle_active'));
|
|
showToast(t('voice_mode_active'),1500);
|
|
// If the agent is busy, wait — state will be 'thinking' and we'll detect completion
|
|
if(typeof S!=='undefined'&&S.busy){
|
|
_setState('thinking');
|
|
return;
|
|
}
|
|
// Cancel any existing TTS
|
|
if(typeof stopTTS==='function') stopTTS();
|
|
_startListening();
|
|
}
|
|
|
|
function _deactivate(){
|
|
_voiceModeActive=false;
|
|
_voiceModeState='idle';
|
|
_voiceModeThinkingSid=null;
|
|
modeBtn.classList.remove('active');
|
|
_setButtonTooltip(modeBtn, t('voice_mode_toggle'));
|
|
bar.style.display='none';
|
|
clearTimeout(_silenceTimer);
|
|
try{ if(_recognition) _recognition.abort(); }catch(_){}
|
|
_recognition=null;
|
|
if(typeof stopTTS==='function') stopTTS();
|
|
// Restore original autoReadLastAssistant
|
|
if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead;
|
|
// Clear textarea if it was only voice input
|
|
ta.value='';
|
|
autoResize();
|
|
}
|
|
|
|
modeBtn.onclick=()=>{
|
|
if(_voiceModeActive){
|
|
_deactivate();
|
|
showToast(t('voice_mode_off'),1500);
|
|
}else{
|
|
_activate();
|
|
}
|
|
};
|
|
|
|
// Expose for external use
|
|
window._voiceModeActive=()=>_voiceModeActive;
|
|
window._voiceModeDeactivate=_deactivate;
|
|
window._voiceModeImmediateSend=_voiceModeSend;
|
|
})();
|
|
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
|
$('btnNewChat').onclick=async()=>{
|
|
// If the current session has no messages AND nothing is in flight, just focus
|
|
// the composer rather than creating another empty session that will clutter the
|
|
// sidebar list (#1171).
|
|
//
|
|
// The "nothing in flight" half is critical (#1432): if the user clicks + while
|
|
// their first message is still streaming (or queued), `message_count` is still 0
|
|
// server-side because the user turn hasn't been merged yet. The old guard treated
|
|
// that as "empty" and made + a no-op for the entire stream duration, so users
|
|
// couldn't actually start a parallel chat. Use the same in-flight signal as
|
|
// `_restoreSettledSession()` in messages.js: an active stream id or a queued
|
|
// pending user message means the session is real, not empty.
|
|
if(S.session
|
|
&& (S.session.message_count||0)===0
|
|
&& !S.busy
|
|
&& !S.session.active_stream_id
|
|
&& !S.session.pending_user_message){
|
|
$('msg').focus();closeMobileSidebar();return;
|
|
}
|
|
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
|
|
};
|
|
$('btnDownload').onclick=()=>{
|
|
if(!S.session)return;
|
|
const blob=new Blob([transcript()],{type:'text/markdown'});
|
|
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
|
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
|
|
};
|
|
$('btnExportJSON').onclick=()=>{
|
|
if(!S.session)return;
|
|
const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
|
|
const a=document.createElement('a');a.href=url;
|
|
a.download=`hermes-${S.session.session_id}.json`;a.click();
|
|
};
|
|
$('btnImportJSON').onclick=()=>$('importFileInput').click();
|
|
$('importFileInput').onchange=async(e)=>{
|
|
const file=e.target.files[0];
|
|
if(!file)return;
|
|
e.target.value='';
|
|
try{
|
|
const text=await file.text();
|
|
const data=JSON.parse(text);
|
|
const res=await api('/api/session/import',{method:'POST',body:JSON.stringify(data)});
|
|
if(res.ok&&res.session){
|
|
await loadSession(res.session.session_id);
|
|
await renderSessionList();
|
|
if(_currentPanel==='settings') switchPanel('chat');
|
|
showToast(t('session_imported'));
|
|
}
|
|
}catch(err){
|
|
showToast(t('import_failed')+(err.message||t('import_invalid_json')));
|
|
}
|
|
};
|
|
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
|
function clearPreview(opts={}){
|
|
const keepPanelOpen=!!(opts&&opts.keepPanelOpen);
|
|
// Restore directory breadcrumb after closing file preview
|
|
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
|
|
const closePanelAfter=_workspacePanelMode==='preview'&&!keepPanelOpen;
|
|
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
|
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
|
|
const pdf=$('previewPdfFrame');if(pdf)pdf.src='';
|
|
const html=$('previewHtmlIframe');if(html)html.src='';
|
|
const pm=$('previewMd');if(pm)pm.innerHTML='';
|
|
const pc=$('previewCode');if(pc)pc.textContent='';
|
|
const pp=$('previewPathText');if(pp)pp.textContent='';
|
|
const ft=$('fileTree');if(ft)ft.style.display='';
|
|
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
|
|
if(closePanelAfter)closeWorkspacePanel();
|
|
else if(keepPanelOpen&&_workspacePanelMode==='preview')openWorkspacePanel('browse');
|
|
else syncWorkspacePanelUI();
|
|
}
|
|
$('btnClearPreview').onclick=handleWorkspaceClose;
|
|
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
|
|
$('modelSelect').onchange=async()=>{
|
|
if(!S.session)return;
|
|
const selectedModel=$('modelSelect').value;
|
|
const modelState=(typeof _modelStateForSelect==='function')
|
|
? _modelStateForSelect($('modelSelect'),selectedModel)
|
|
: {model:selectedModel,model_provider:null};
|
|
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{}
|
|
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
|
session_id:S.session.session_id,
|
|
workspace:S.session.workspace,
|
|
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();
|
|
// 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);
|
|
}
|
|
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
|
if(typeof _checkProviderMismatch==='function'){
|
|
const warn=_checkProviderMismatch(selectedModel);
|
|
if(warn&&typeof showToast==='function') showToast(warn,4000);
|
|
}
|
|
};
|
|
$('msg').addEventListener('input',()=>{
|
|
autoResize();
|
|
updateSendBtn();
|
|
// Persist composer draft to server (debounced in _saveComposerDraft).
|
|
const sid = S && S.session && S.session.session_id;
|
|
if (sid && typeof _saveComposerDraft === 'function') {
|
|
_saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
|
|
}
|
|
const text=$('msg').value;
|
|
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
|
if(typeof getSlashAutocompleteMatches==='function'){
|
|
getSlashAutocompleteMatches(text).then(matches=>{
|
|
if(($('msg').value||'')!==text) return;
|
|
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
|
});
|
|
}else{
|
|
const prefix=text.slice(1);
|
|
const matches=getMatchingCommands(prefix);
|
|
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
|
}
|
|
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
|
|
} else {
|
|
hideCmdDropdown();
|
|
}
|
|
});
|
|
// Track IME composition for East Asian input. Safari fires the committing
|
|
// keydown AFTER compositionend with isComposing=false, so we also keep a
|
|
// manual flag and reset it on the next tick to swallow that trailing Enter.
|
|
// Also reset on blur so the flag can never get stuck in a true state if
|
|
// compositionend never fires (focus loss with some IME implementations).
|
|
//
|
|
// The `_imeComposing` flag is bound to the chat composer (`#msg`); other
|
|
// inputs (session/project rename, app dialog, message edit, workspace rename)
|
|
// rely on the state-free `e.isComposing || e.keyCode === 229` part of
|
|
// `_isImeEnter`, which is sufficient for the Safari race because keyCode 229
|
|
// is the canonical "still composing" signal regardless of which field is
|
|
// focused. Promote `_isImeEnter` to `window` so other modules can reuse it
|
|
// without duplicating the full IIFE per input (issue #1443).
|
|
let _imeComposing=false;
|
|
(()=>{const _c=$('msg');if(!_c)return;
|
|
_c.addEventListener('compositionstart',()=>{_imeComposing=true;});
|
|
_c.addEventListener('compositionend',()=>{setTimeout(()=>{_imeComposing=false;},0);});
|
|
_c.addEventListener('blur',()=>{_imeComposing=false;});
|
|
})();
|
|
function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
|
|
window._isImeEnter=_isImeEnter;
|
|
$('msg').addEventListener('keydown',e=>{
|
|
// Autocomplete navigation when dropdown is open
|
|
const dd=$('cmdDropdown');
|
|
const dropdownOpen=dd&&dd.classList.contains('open');
|
|
if(dropdownOpen){
|
|
if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
|
|
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
|
|
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
|
|
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
|
|
if(e.key==='Enter'&&!e.shiftKey){
|
|
if(_isImeEnter(e)){return;}
|
|
e.preventDefault();
|
|
selectCmdDropdownItem();
|
|
return;
|
|
}
|
|
}
|
|
// Send key: respect user preference.
|
|
// On touch-primary devices (software keyboard), default to Enter = newline
|
|
// since there's no physical Shift key. Users send via the Send button.
|
|
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
|
|
// Users can override in Settings by explicitly choosing 'enter' mode.
|
|
if(e.key==='Enter'){
|
|
if(_isImeEnter(e)){return;}
|
|
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
|
|
if(window._sendKey==='ctrl+enter'||_mobileDefault){
|
|
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
|
|
} else {
|
|
if(!e.shiftKey){e.preventDefault();send();}
|
|
}
|
|
}
|
|
});
|
|
// B14: Cmd/Ctrl+K creates a new chat from anywhere
|
|
document.addEventListener('keydown',async e=>{
|
|
// Cmd/Ctrl+B toggles desktop sidebar collapse (VS Code convention).
|
|
// Skip when typing in an input/textarea/contenteditable so text-edit
|
|
// shortcuts (e.g. bold in some embedded editors) are never stolen.
|
|
if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&!e.altKey&&(e.key==='b'||e.key==='B')){
|
|
const t=e.target;
|
|
const isText=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);
|
|
if(!isText&&typeof toggleSidebar==='function'&&_isDesktopWidth()){
|
|
e.preventDefault();
|
|
toggleSidebar();
|
|
return;
|
|
}
|
|
}
|
|
// Enter on approval card = Allow once (when a button inside the card is focused or
|
|
// card is visible and focus is not on an input/textarea/select)
|
|
if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
|
|
const card=$('approvalCard');
|
|
const tag=(document.activeElement||{}).tagName||'';
|
|
if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
|
|
e.preventDefault();
|
|
if(typeof respondApproval==='function') respondApproval('once');
|
|
return;
|
|
}
|
|
}
|
|
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
|
e.preventDefault();
|
|
// If the current session has no messages AND nothing is in flight, just focus
|
|
// the composer rather than creating another empty session that will clutter
|
|
// the sidebar list (#1171). See the matching guard in $('btnNewChat').onclick
|
|
// and bug #1432 for why the in-flight check is needed.
|
|
if(S.session
|
|
&& (S.session.message_count||0)===0
|
|
&& !S.busy
|
|
&& !S.session.active_stream_id
|
|
&& !S.session.pending_user_message){
|
|
$('msg').focus();return;
|
|
}
|
|
// Cmd/Ctrl+K should always create a new conversation, even while the current
|
|
// one is still streaming. The old !S.busy guard meant users had to wait for
|
|
// a long generation to finish before they could start something new — exactly
|
|
// the moment they want to switch context. newSession() leaves the in-flight
|
|
// stream running on its own session; the user just gets a fresh blank one.
|
|
await newSession();await renderSessionList();closeMobileSidebar();$('msg').focus();
|
|
}
|
|
if(e.key==='Escape'){
|
|
// Close onboarding overlay if open (skip/dismiss the wizard)
|
|
const onboardingOverlay=$('onboardingOverlay');
|
|
if(onboardingOverlay&&onboardingOverlay.style.display!=='none'){
|
|
if(typeof skipOnboarding==='function') skipOnboarding();
|
|
return;
|
|
}
|
|
// Close settings panel if active
|
|
if(_currentPanel==='settings'){_closeSettingsPanel();return;}
|
|
// Close workspace dropdown
|
|
closeWsDropdown();
|
|
// Clear session search
|
|
const ss=$('sessionSearch');
|
|
if(ss&&ss.value){ss.value='';filterSessions();}
|
|
// Cancel any active message edit
|
|
const editArea=document.querySelector('.msg-edit-area');
|
|
if(editArea){
|
|
const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
|
|
if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
|
|
}
|
|
}
|
|
});
|
|
$('msg').addEventListener('paste',e=>{
|
|
const items=Array.from(e.clipboardData?.items||[]);
|
|
// When the clipboard carries BOTH text and an image (common from Notes,
|
|
// Word, browsers, Slack — the OS attaches a rendered preview alongside
|
|
// the plain text), prefer the text and let the browser paste normally.
|
|
// Only intercept when the clipboard is image-only (true screenshot paste).
|
|
// Tighten the image filter to kind==='file' so string items advertising an
|
|
// image MIME (e.g. text/html with an embedded data URI) are not misclassified.
|
|
const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
|
|
const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
|
if(!imageItems.length||hasText)return;
|
|
e.preventDefault();
|
|
const pasteTs=Date.now();
|
|
const files=imageItems.map((i,idx)=>{
|
|
const blob=i.getAsFile();
|
|
const ext=i.type.split('/')[1]||'png';
|
|
const suffix=imageItems.length>1?`-${idx+1}`:'';
|
|
return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type});
|
|
});
|
|
addFiles(files);
|
|
setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
|
|
});
|
|
document.querySelectorAll('.suggestion').forEach(btn=>{
|
|
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
|
|
});
|
|
|
|
window.addEventListener('resize',()=>{
|
|
_syncWorkspacePanelInlineWidth();
|
|
syncWorkspacePanelState();
|
|
});
|
|
|
|
// Boot: restore last session or start fresh
|
|
// ── Resizable panels ──────────────────────────────────────────────────────
|
|
(function(){
|
|
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
|
|
const PANEL_MIN=180, PANEL_MAX=1200;
|
|
|
|
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
|
|
const handle = $(handleId);
|
|
if(!handle || !targetEl) return;
|
|
|
|
// Restore saved width
|
|
if(storageKey === 'hermes-panel-w'){
|
|
_syncWorkspacePanelInlineWidth();
|
|
}else{
|
|
const saved = localStorage.getItem(storageKey);
|
|
if(saved) targetEl.style.width = saved + 'px';
|
|
}
|
|
|
|
let startX=0, startW=0;
|
|
|
|
handle.addEventListener('mousedown', e=>{
|
|
e.preventDefault();
|
|
startX = e.clientX;
|
|
startW = targetEl.getBoundingClientRect().width;
|
|
handle.classList.add('dragging');
|
|
document.body.classList.add('resizing');
|
|
|
|
const onMove = ev=>{
|
|
const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX;
|
|
const newW = Math.min(maxW, Math.max(minW, startW + delta));
|
|
targetEl.style.width = newW + 'px';
|
|
};
|
|
const onUp = ()=>{
|
|
handle.classList.remove('dragging');
|
|
document.body.classList.remove('resizing');
|
|
localStorage.setItem(storageKey, parseInt(targetEl.style.width));
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
});
|
|
}
|
|
|
|
// Run after DOM ready (called from boot)
|
|
window._initResizePanels = function(){
|
|
const sidebar = document.querySelector('.sidebar');
|
|
const rightpanel = document.querySelector('.rightpanel');
|
|
initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w');
|
|
initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w');
|
|
};
|
|
})();
|
|
|
|
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
|
|
const _THEMES=[
|
|
{name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']},
|
|
{name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']},
|
|
{name:'System', value:'system', colors:['#FEFCF7','#0D0D1A','#B8860B']},
|
|
];
|
|
const _SKINS=[
|
|
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
|
|
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
|
|
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
|
|
{name:'Slate', colors:['#334155','#475569','#64748b']},
|
|
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
|
|
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
|
|
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
|
|
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
|
|
];
|
|
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
|
|
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
|
|
const _LEGACY_THEME_MAP={
|
|
slate:{theme:'dark',skin:'slate'},
|
|
solarized:{theme:'dark',skin:'poseidon'},
|
|
monokai:{theme:'dark',skin:'sisyphus'},
|
|
nord:{theme:'dark',skin:'slate'},
|
|
oled:{theme:'dark',skin:'default'},
|
|
};
|
|
let _systemThemeMq=null;
|
|
let _onSystemThemeChange=null;
|
|
|
|
function _normalizeAppearance(theme,skin){
|
|
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
|
|
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
|
|
const legacy=_LEGACY_THEME_MAP[rawTheme];
|
|
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
|
|
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
|
|
return {theme:nextTheme,skin:nextSkin};
|
|
}
|
|
|
|
// Sync <meta name="theme-color"> with the active theme's app chrome color.
|
|
// This surfaces the WebUI's exact theme background to:
|
|
// 1. Mobile Safari status bar (the prefers-color-scheme media variants in index.html
|
|
// cover the pre-load case; this updater handles user-toggled changes mid-session).
|
|
// 2. iOS PWA / Add to Home Screen status bar.
|
|
// 3. Native WKWebView wrappers (e.g. hermes-swift-mac) that read this attribute as
|
|
// the source of truth for AppKit chrome (tab bar, title bar, traffic-light area)
|
|
// instead of pixel-sampling — overlay-resistant and IPC-free.
|
|
// Reading getComputedStyle(html).getPropertyValue('--sidebar') picks up the active skin
|
|
// (Default, Sienna, Sisyphus, Charizard, etc.) so each skin's distinct paint reaches
|
|
// the meta tag.
|
|
function _syncThemeColorMeta(){
|
|
try{
|
|
const bg=getComputedStyle(document.documentElement).getPropertyValue('--sidebar').trim();
|
|
if(!bg) return;
|
|
const known=document.getElementById('hermes-theme-color');
|
|
if(known){
|
|
known.setAttribute('content',bg);
|
|
known.removeAttribute('media');
|
|
}
|
|
document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
|
|
meta.setAttribute('content',bg);
|
|
meta.removeAttribute('media');
|
|
});
|
|
}catch(e){}
|
|
}
|
|
|
|
function _setResolvedTheme(isDark){
|
|
document.documentElement.classList.toggle('dark',!!isDark);
|
|
const link=document.getElementById('prism-theme');
|
|
if(!link){ _syncThemeColorMeta(); return; }
|
|
const want=isDark
|
|
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
|
|
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
|
|
// No SRI integrity on theme CSS — jsdelivr edge nodes serve different
|
|
// digests for the same pinned version, causing intermittent blocking (#1100).
|
|
if(link.href!==want){ link.integrity=''; link.href=want; }
|
|
_syncThemeColorMeta();
|
|
}
|
|
|
|
function _applyTheme(name){
|
|
const normalized=_normalizeAppearance(name,'default');
|
|
delete document.documentElement.dataset.theme;
|
|
if(_systemThemeMq&&_onSystemThemeChange){
|
|
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
|
|
_systemThemeMq=null;
|
|
_onSystemThemeChange=null;
|
|
}
|
|
if(normalized.theme==='system'){
|
|
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
|
|
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
|
|
_setResolvedTheme(_systemThemeMq.matches);
|
|
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
|
|
return;
|
|
}
|
|
_setResolvedTheme(normalized.theme==='dark');
|
|
}
|
|
|
|
function _applySkin(name){
|
|
const key=(name||'default').toLowerCase();
|
|
if(key==='default') delete document.documentElement.dataset.skin;
|
|
else document.documentElement.dataset.skin=key;
|
|
_syncThemeColorMeta();
|
|
}
|
|
|
|
function _pickTheme(name){
|
|
const currentSkin=localStorage.getItem('hermes-skin');
|
|
const appearance=_normalizeAppearance(name,currentSkin);
|
|
localStorage.setItem('hermes-theme',appearance.theme);
|
|
localStorage.setItem('hermes-skin',appearance.skin);
|
|
_applyTheme(appearance.theme);
|
|
_applySkin(appearance.skin);
|
|
_syncThemePicker(appearance.theme);
|
|
_syncSkinPicker(appearance.skin);
|
|
const hidden=$('settingsTheme');
|
|
if(hidden) hidden.value=appearance.theme;
|
|
const skinHidden=$('settingsSkin');
|
|
if(skinHidden) skinHidden.value=appearance.skin;
|
|
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
|
|
}
|
|
|
|
function _pickSkin(name){
|
|
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
|
|
localStorage.setItem('hermes-theme',appearance.theme);
|
|
localStorage.setItem('hermes-skin',appearance.skin);
|
|
_applyTheme(appearance.theme);
|
|
_applySkin(appearance.skin);
|
|
_syncThemePicker(appearance.theme);
|
|
_syncSkinPicker(appearance.skin);
|
|
const hidden=$('settingsSkin');
|
|
if(hidden) hidden.value=appearance.skin;
|
|
const themeHidden=$('settingsTheme');
|
|
if(themeHidden) themeHidden.value=appearance.theme;
|
|
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
|
|
}
|
|
|
|
function _syncThemePicker(active){
|
|
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
|
|
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=>{
|
|
btn.classList.toggle('active',btn.dataset.skinVal===active);
|
|
btn.style.borderColor='';
|
|
btn.style.boxShadow='';
|
|
});
|
|
}
|
|
|
|
function _applyFontSize(size){
|
|
if(size&&size!=='default'){
|
|
document.documentElement.dataset.fontSize=size;
|
|
} else {
|
|
delete document.documentElement.dataset.fontSize;
|
|
}
|
|
}
|
|
|
|
function _pickFontSize(size){
|
|
localStorage.setItem('hermes-font-size',size);
|
|
_applyFontSize(size);
|
|
_syncFontSizePicker(size);
|
|
const hidden=$('settingsFontSize');
|
|
if(hidden) hidden.value=size;
|
|
if(typeof _scheduleAppearanceAutosave==='function') _scheduleAppearanceAutosave();
|
|
}
|
|
|
|
function _syncFontSizePicker(active){
|
|
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{
|
|
btn.classList.toggle('active',btn.dataset.fontSizeVal===(active||'default'));
|
|
btn.style.borderColor='';
|
|
btn.style.boxShadow='';
|
|
});
|
|
}
|
|
|
|
function _buildSkinPicker(activeSkin){
|
|
const grid=$('skinPickerGrid');
|
|
if(!grid) return;
|
|
grid.innerHTML='';
|
|
for(const skin of _SKINS){
|
|
const key=skin.name.toLowerCase();
|
|
const btn=document.createElement('button');
|
|
btn.type='button';
|
|
btn.className='skin-pick-btn';
|
|
btn.dataset.skinVal=key;
|
|
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
|
|
btn.onclick=()=>_pickSkin(skin.name);
|
|
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
|
|
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
|
|
grid.appendChild(btn);
|
|
}
|
|
_syncSkinPicker((activeSkin||'default').toLowerCase());
|
|
}
|
|
|
|
function applyBotName(){
|
|
// Prefer profile name over global bot_name for personalised placeholder.
|
|
// If activeProfile is set and not 'default', use it (capitalised).
|
|
// Falls back to window._botName (global bot_name setting) or 'Hermes'.
|
|
let name;
|
|
if(S.activeProfile && S.activeProfile!=='default'){
|
|
name=S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1);
|
|
}else{
|
|
name=window._botName||'Hermes';
|
|
}
|
|
document.title=name;
|
|
const sidebarH1=document.querySelector('.sidebar-header h1');
|
|
if(sidebarH1) sidebarH1.textContent=name;
|
|
const logo=document.querySelector('.sidebar-header .logo');
|
|
if(logo) logo.textContent=name.charAt(0).toUpperCase();
|
|
const topbarTitle=$('topbarTitle');
|
|
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
|
|
const msg=$('msg');
|
|
if(msg) msg.placeholder='Message '+name+'\u2026';
|
|
}
|
|
|
|
(async()=>{
|
|
// Load send key preference
|
|
let _bootSettings={};
|
|
try{
|
|
const s=await api('/api/settings');
|
|
_bootSettings=s;
|
|
window._sendKey=s.send_key||'enter';
|
|
window._showTokenUsage=!!s.show_token_usage;
|
|
window._showTps=!!s.show_tps;
|
|
window._fadeTextEffect=!!s.fade_text_effect;
|
|
window._showCliSessions=!!s.show_cli_sessions;
|
|
window._soundEnabled=!!s.sound_enabled;
|
|
window._notificationsEnabled=!!s.notifications_enabled;
|
|
// Persist default workspace so the blank new-chat page can show it
|
|
// and workspace actions (New file/folder) work before the first session (#804).
|
|
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
|
|
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
|
|
window._showThinking=s.show_thinking!==false;
|
|
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
|
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
|
window._busyInputMode=(s.busy_input_mode||'queue');
|
|
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
|
window._botName=s.bot_name||'Hermes';
|
|
if(s.default_model) window._defaultModel=s.default_model;
|
|
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
|
|
// Reconcile appearance: prefer localStorage (what the user last saw) over
|
|
// the server. If they diverge (e.g. a previous autosave POST failed),
|
|
// push the localStorage values back to the server so settings.json stays
|
|
// in sync without ever clobbering the user's chosen theme/skin.
|
|
//
|
|
// Caveat: the pre-paint inline script in index.html normalises empty
|
|
// localStorage into 'dark'/'default' BEFORE this code runs, so a truly
|
|
// empty (new-browser) state is indistinguishable from a user who chose
|
|
// the defaults. To avoid blocking server→client sync on first visit we
|
|
// only let localStorage override the server when it carries a NON-DEFAULT
|
|
// skin or a non-dark/light theme value (i.e. the user explicitly picked
|
|
// something). When localStorage is at the defaults, the server wins.
|
|
const srvAppearance=_normalizeAppearance(s.theme,s.skin);
|
|
const lsTheme=(localStorage.getItem('hermes-theme')||'').trim().toLowerCase();
|
|
const lsSkin=(localStorage.getItem('hermes-skin')||'').trim().toLowerCase();
|
|
const lsAppearance=_normalizeAppearance(lsTheme||null,lsSkin||null);
|
|
const lsHasExplicitSkin=lsSkin&&lsSkin!=='default';
|
|
const lsHasExplicitTheme=lsTheme&&lsTheme==='system';
|
|
const theme=lsHasExplicitTheme?lsAppearance.theme:srvAppearance.theme;
|
|
const skin=lsHasExplicitSkin?lsAppearance.skin:srvAppearance.skin;
|
|
localStorage.setItem('hermes-theme',theme);
|
|
_applyTheme(theme);
|
|
localStorage.setItem('hermes-skin',skin);
|
|
_applySkin(skin);
|
|
// Reconcile: if localStorage and server disagree, push localStorage
|
|
// values to the server so the next refresh won't revert.
|
|
if((lsHasExplicitTheme||lsHasExplicitSkin)&&(theme!==srvAppearance.theme||skin!==srvAppearance.skin)){
|
|
try{
|
|
api('/api/settings',{method:'POST',body:JSON.stringify({theme,skin})});
|
|
}catch(_){}
|
|
}
|
|
const fontSize=(s.font_size||localStorage.getItem('hermes-font-size')||'default');
|
|
localStorage.setItem('hermes-font-size',fontSize);
|
|
_applyFontSize(fontSize);
|
|
if(typeof setLocale==='function'){
|
|
const _lang=typeof resolvePreferredLocale==='function'
|
|
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
|
: (s.language || localStorage.getItem('hermes-lang') || 'en');
|
|
setLocale(_lang);
|
|
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
|
}
|
|
applyBotName();
|
|
// TTS: apply enabled state on boot so buttons show/hide correctly (#499)
|
|
if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
|
|
}catch(e){
|
|
window._sendKey='enter';
|
|
window._showTokenUsage=false;
|
|
window._showTps=false;
|
|
window._fadeTextEffect=false;
|
|
window._showCliSessions=false;
|
|
window._soundEnabled=false;
|
|
window._notificationsEnabled=false;
|
|
window._whatsNewSummaryEnabled=false;
|
|
window._showThinking=true;
|
|
window._simplifiedToolCalling=true;
|
|
window._sessionJumpButtonsEnabled=false;
|
|
window._sidebarDensity='compact';
|
|
window._busyInputMode='queue';
|
|
window._sessionEndlessScrollEnabled=false;
|
|
window._botName='Hermes';
|
|
_bootSettings={check_for_updates:false};
|
|
if(typeof setLocale==='function'){
|
|
const _lang=typeof resolvePreferredLocale==='function'
|
|
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
|
|
: (localStorage.getItem('hermes-lang') || 'en');
|
|
setLocale(_lang);
|
|
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
|
}
|
|
applyBotName();
|
|
if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true');
|
|
}
|
|
// Non-blocking update check (fire-and-forget, once per tab session)
|
|
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
|
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
|
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
|
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
|
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
|
}
|
|
// Fetch active profile
|
|
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
|
// Update profile chip label immediately
|
|
const profileLabel=$('profileChipLabel');
|
|
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
|
// Fetch available models without blocking session restore. The static HTML
|
|
// options are enough for first paint; the dynamic provider list can settle
|
|
// after the saved session is visible.
|
|
const _modelDropdownReady=populateModelDropdown().then(()=>{
|
|
const savedState=(typeof _readPersistedModelState==='function')
|
|
? _readPersistedModelState()
|
|
: (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
|
|
const savedModel=savedState&&savedState.model;
|
|
if(savedModel && $('modelSelect')){
|
|
const applied=(typeof _applyModelToDropdown==='function')
|
|
? _applyModelToDropdown(savedModel,$('modelSelect'),savedState.model_provider||null)
|
|
: null;
|
|
if(!applied) $('modelSelect').value=savedModel;
|
|
// If the value didn't take (model not in list), clear the bad pref
|
|
if(!applied&&$('modelSelect').value!==savedModel){
|
|
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
|
|
else localStorage.removeItem('hermes-webui-model');
|
|
}
|
|
else if(typeof syncModelChip==='function') syncModelChip();
|
|
}
|
|
if(S.session) syncTopbar();
|
|
}).catch(()=>{});
|
|
window._modelDropdownReady=_modelDropdownReady;
|
|
// Pre-load workspace list so sidebar name is correct from first render.
|
|
// Render the session list before restoring the saved conversation so a stale
|
|
// saved-session/client-side boot error cannot leave the sidebar empty forever.
|
|
await loadWorkspaceList();
|
|
await loadOnboardingWizard();
|
|
await renderSessionList();
|
|
_initResizePanels();
|
|
// Workspace panel restore happens AFTER loadSession so we know if
|
|
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
|
|
// Fix #822: clear any browser-restored value before first render. This
|
|
// covers fresh page loads and reloads. The bfcache restore case is handled
|
|
// separately below by a `pageshow` listener — the async IIFE here does NOT
|
|
// re-run when the browser restores the page from bfcache.
|
|
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
|
|
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
|
|
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
|
|
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
|
const savedLocal=localStorage.getItem('hermes-webui-session');
|
|
const saved=urlSession||savedLocal;
|
|
if(saved){
|
|
try{
|
|
if(!urlSession&&savedLocal&&await _savedSessionShouldStaySidebarOnly(savedLocal)){
|
|
S.session=null; S.messages=[]; S.activeStreamId=null; S.busy=false;
|
|
S._bootReady=true;
|
|
syncTopbar();syncWorkspacePanelState();
|
|
$('emptyState').style.display='';
|
|
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
|
|
return;
|
|
}
|
|
await loadSession(saved);
|
|
// If the restored session has no messages it is an ephemeral scratch pad —
|
|
// treat the page as a fresh start rather than resuming a blank conversation.
|
|
// loadSession() already ran, so loadDir() has populated the workspace file tree.
|
|
// Do NOT remove the session ID from localStorage — keeping it means every
|
|
// subsequent refresh will also run loadSession() → loadDir() → files stay visible.
|
|
// Removing it here caused the file tree to go blank on the second refresh
|
|
// because the "no saved session" path never calls loadDir (#workspace-files).
|
|
const _restoredInFlight = S.session && (
|
|
S.session.active_stream_id ||
|
|
S.session.pending_user_message
|
|
);
|
|
if(S.session && (S.session.message_count||0) === 0 && !_restoredInFlight){
|
|
S.session=null; S.messages=[];
|
|
S._bootReady=true;
|
|
// Restore panel pref before syncing so the workspace panel stays visible
|
|
// even though there is no active session (#workspace-persist).
|
|
const _ephPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
|
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
|
if(_ephPanelPref) _workspacePanelMode='browse';
|
|
syncTopbar();syncWorkspacePanelState();
|
|
$('emptyState').style.display='';
|
|
await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();
|
|
return;
|
|
}
|
|
// Restore the panel from localStorage when the session has a workspace.
|
|
// Preference key takes priority over runtime state so that closing
|
|
// the panel via toolbar X doesn't suppress the "keep open" setting.
|
|
const panelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
|
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
|
if(S.session&&S.session.workspace&&panelPref){
|
|
_workspacePanelMode='browse';
|
|
}
|
|
S._bootReady=true;
|
|
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
|
catch(e){localStorage.removeItem('hermes-webui-session');}
|
|
}
|
|
// no saved session - show empty state, wait for user to hit +
|
|
S._bootReady=true;
|
|
syncTopbar();
|
|
// Restore panel pref so the workspace panel stays visible on a fresh load if the
|
|
// user had it open during their last session (#workspace-persist).
|
|
const _freshPanelPref=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open'
|
|
|| localStorage.getItem('hermes-webui-workspace-panel')==='open';
|
|
if(_freshPanelPref) _workspacePanelMode='browse';
|
|
syncWorkspacePanelState();
|
|
$('emptyState').style.display='';
|
|
await renderSessionList();
|
|
// Start real-time gateway session sync if setting is enabled
|
|
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
|
})().catch(e=>{
|
|
console.error('[hermes] boot failed', e);
|
|
try{S._bootReady=true;}catch(_){}
|
|
try{syncTopbar();}catch(_){}
|
|
try{syncWorkspacePanelState();}catch(_){}
|
|
try{$('emptyState').style.display='';}catch(_){}
|
|
try{if(typeof renderSessionList==='function') void renderSessionList();}catch(_){}
|
|
});
|
|
|
|
// Fix #822 (bfcache path): when the browser restores the page from the
|
|
// back-forward cache, the async boot IIFE above does NOT re-run, but the
|
|
// DOM — including any stale value in #sessionSearch — IS restored. A
|
|
// prior search string would silently hide all sessions via the filter in
|
|
// renderSessionListFromCache(). Clear the field and re-run the full layout
|
|
// sync whenever the page is restored from cache (`event.persisted === true`).
|
|
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
|
|
// chrome aren't left in the stale bfcache snapshot.
|
|
window.addEventListener('pageshow', async (event) => {
|
|
if (!event.persisted) return; // fresh loads are handled by the IIFE above
|
|
const _srch = document.getElementById('sessionSearch');
|
|
if (_srch) _srch.value = '';
|
|
// Close any dropdowns/popovers that were open when the user navigated away.
|
|
// bfcache freezes DOM state, so a dropdown left open remains open on restore.
|
|
if (typeof closeModelDropdown === 'function') try { closeModelDropdown(); } catch (_) {}
|
|
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
|
|
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
|
|
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
|
|
// BFCache restores the frozen DOM without rerunning boot. Refresh the active
|
|
// session through the normal load path so in-flight sessions with
|
|
// active_stream_id / pending_user_message can reattach like a reload restore.
|
|
if (S.session && S.session.session_id && typeof loadSession === 'function') {
|
|
try {
|
|
await loadSession(S.session.session_id);
|
|
if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
|
|
try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
|
|
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
|
|
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}
|
|
if (typeof syncWorkspacePanelState === 'function') try { syncWorkspacePanelState(); } catch (_) {}
|
|
if (typeof renderSessionListFromCache === 'function') {
|
|
try { renderSessionListFromCache(); } catch (_) {}
|
|
}
|
|
// Restart the gateway SSE watcher — the persisted connection is dead after bfcache
|
|
if (typeof startGatewaySSE === 'function') try { startGatewaySSE(); } catch (_) {}
|
|
// Re-sync sidebar collapse state from localStorage. bfcache restored the
|
|
// frozen DOM but another tab may have toggled the sidebar in the meantime.
|
|
if (typeof _isSidebarCollapsed === 'function' && typeof toggleSidebar === 'function') {
|
|
try {
|
|
const _want = localStorage.getItem('hermes-webui-sidebar-collapsed') === '1';
|
|
const _have = _isSidebarCollapsed();
|
|
if (_want !== _have) toggleSidebar(_want);
|
|
if (typeof _syncSidebarAria === 'function') _syncSidebarAria();
|
|
} catch (_) {}
|
|
}
|
|
});
|