🔧 Initial dev copy from live
This commit is contained in:
844
static/boot.js
Normal file
844
static/boot.js
Normal file
@@ -0,0 +1,844 @@
|
||||
async function cancelStream(){
|
||||
const streamId = S.activeStreamId;
|
||||
if(!streamId) return;
|
||||
try{
|
||||
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,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.
|
||||
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
||||
S.activeStreamId=null;
|
||||
setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
else setStatus('');
|
||||
}
|
||||
|
||||
// ── Mobile navigation ──────────────────────────────────────────────────────
|
||||
let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview'
|
||||
|
||||
function _isCompactWorkspaceViewport(){
|
||||
return window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
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)
|
||||
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){
|
||||
_setWorkspacePanelMode('closed');
|
||||
return;
|
||||
}
|
||||
_setWorkspacePanelMode(_workspacePanelMode==='preview'?'closed':_workspacePanelMode);
|
||||
}
|
||||
|
||||
function openWorkspacePanel(mode='browse'){
|
||||
if(mode==='browse'&&!S.session&&!_hasWorkspacePreviewVisible())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();
|
||||
}
|
||||
|
||||
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();
|
||||
const hasPreview=_hasWorkspacePreviewVisible();
|
||||
if(toggleBtn){
|
||||
toggleBtn.classList.toggle('active',isOpen);
|
||||
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
|
||||
toggleBtn.title=isOpen?'Hide workspace panel':'Show workspace panel';
|
||||
toggleBtn.disabled=!canBrowse;
|
||||
}
|
||||
if(collapseBtn){
|
||||
collapseBtn.title=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;
|
||||
clearBtn.title=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');
|
||||
}
|
||||
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(window._micActive){
|
||||
window._micPendingSend=true;
|
||||
_stopMic();
|
||||
return;
|
||||
}
|
||||
send();
|
||||
};
|
||||
$('btnAttach').onclick=()=>$('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);
|
||||
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;
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{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();
|
||||
const overlay=$('settingsOverlay');
|
||||
if(overlay) overlay.style.display='none';
|
||||
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(){
|
||||
const closePanelAfter=_workspacePanelMode==='preview';
|
||||
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
||||
const pi=$('previewImg');if(pi){pi.onerror=null;pi.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='';
|
||||
const wsSearchClear=$('wsSearchWrap');if(wsSearchClear)wsSearchClear.style.display='';
|
||||
_previewCurrentPath='';_previewCurrentMode='';_previewDirty=false;
|
||||
// Restore directory breadcrumb after closing file preview
|
||||
if(typeof renderBreadcrumb==='function') renderBreadcrumb();
|
||||
if(closePanelAfter)closeWorkspacePanel();
|
||||
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;
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
syncTopbar();
|
||||
// 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);
|
||||
}
|
||||
// Notify user that model changes only take effect in the next conversation (#419)
|
||||
if(S.messages && S.messages.length > 0 && typeof showToast==='function'){
|
||||
showToast('Model change takes effect in your next conversation', 3000);
|
||||
}
|
||||
};
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
const prefix=text.slice(1);
|
||||
const matches=getMatchingCommands(prefix);
|
||||
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
|
||||
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
|
||||
} else {
|
||||
hideCmdDropdown();
|
||||
}
|
||||
});
|
||||
$('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(e.isComposing){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(e.isComposing){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=>{
|
||||
// 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(!S.busy){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 overlay if open
|
||||
const settingsOverlay=$('settingsOverlay');
|
||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_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||[]);
|
||||
const imageItems=items.filter(i=>i.type.startsWith('image/'));
|
||||
if(!imageItems.length)return;
|
||||
e.preventDefault();
|
||||
const files=imageItems.map(i=>{
|
||||
const blob=i.getAsFile();
|
||||
const ext=i.type.split('/')[1]||'png';
|
||||
return new File([blob],`screenshot-${Date.now()}.${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',()=>{
|
||||
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
|
||||
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 _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']},
|
||||
];
|
||||
const _VALID_THEMES=new Set(['system','dark','light']);
|
||||
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};
|
||||
}
|
||||
|
||||
function _setResolvedTheme(isDark){
|
||||
document.documentElement.classList.toggle('dark',!!isDark);
|
||||
const link=document.getElementById('prism-theme');
|
||||
if(!link) 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';
|
||||
if(link.href!==want){ link.href=want; }
|
||||
}
|
||||
|
||||
function _applyTheme(name){
|
||||
const normalized=_normalizeAppearance(name,'default');
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsTheme');
|
||||
if(hidden) hidden.value=appearance.theme;
|
||||
const skinHidden=$('settingsSkin');
|
||||
if(skinHidden) skinHidden.value=appearance.skin;
|
||||
}
|
||||
|
||||
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);
|
||||
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
|
||||
const hidden=$('settingsSkin');
|
||||
if(hidden) hidden.value=appearance.skin;
|
||||
const themeHidden=$('settingsTheme');
|
||||
if(themeHidden) themeHidden.value=appearance.theme;
|
||||
}
|
||||
|
||||
function _syncThemePicker(active){
|
||||
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.themeVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _syncSkinPicker(active){
|
||||
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
|
||||
const sel=btn.dataset.skinVal===active;
|
||||
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
|
||||
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
|
||||
});
|
||||
}
|
||||
|
||||
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(){
|
||||
const 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._showCliSessions=!!s.show_cli_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
const appearance=_normalizeAppearance(s.theme,s.skin);
|
||||
localStorage.setItem('hermes-theme',appearance.theme);
|
||||
_applyTheme(appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applySkin(appearance.skin);
|
||||
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
|
||||
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();
|
||||
}catch(e){
|
||||
window._sendKey='enter';
|
||||
window._showTokenUsage=false;
|
||||
window._showCliSessions=false;
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
window._botName='Hermes';
|
||||
_bootSettings={check_for_updates:false};
|
||||
document.body.classList.remove('bubble-layout');
|
||||
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();
|
||||
}
|
||||
// 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 from server and populate dropdown dynamically
|
||||
await populateModelDropdown();
|
||||
// Load commands from Hermes COMMAND_REGISTRY before enabling input
|
||||
await loadCommands();
|
||||
// Restore last-used model preference
|
||||
const savedModel=localStorage.getItem('hermes-webui-model');
|
||||
if(savedModel && $('modelSelect')){
|
||||
$('modelSelect').value=savedModel;
|
||||
// If the value didn't take (model not in list), clear the bad pref
|
||||
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
await loadOnboardingWizard();
|
||||
_initResizePanels();
|
||||
// Workspace panel restore happens AFTER loadSession so we know if
|
||||
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
try{
|
||||
await loadSession(saved);
|
||||
// Only restore the panel from localStorage when the session actually has a workspace.
|
||||
// Without this guard, sessions without a workspace snap open then immediately closed.
|
||||
if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){
|
||||
_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();
|
||||
syncWorkspacePanelState();
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})();
|
||||
Reference in New Issue
Block a user