🔧 Initial dev copy from live

This commit is contained in:
Rose
2026-04-20 10:43:30 +02:00
commit 96977b576a
284 changed files with 95780 additions and 0 deletions

844
static/boot.js Normal file
View 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();
})();

379
static/commands.js Normal file
View File

@@ -0,0 +1,379 @@
// ── Slash commands ──────────────────────────────────────────────────────────
// Commands are loaded dynamically from GET /api/commands (Hermes COMMAND_REGISTRY).
// Tier-2 Agent commands and passthrough handlers are added client-side.
// Each command either runs locally or is forwarded as a message to the agent.
let COMMANDS=[]; // Loaded async via loadCommands()
// Map Hermes passthrough command names to their fn.
// These commands are forwarded to the agent as-is.
const _PASSTHROUGH=['retry','undo','title','branch','stop','background','btw',
'queue','status','profile','resume','snapshot','rollback','provider',
'yolo','reasoning','fast','voice','reload','reload-mcp','cron','browser',
'plugins','insights','platforms','debug','update','image','inbox'];
function _fnFor(name){
if(name==='help'||name==='commands') return cmdHelp;
if(name==='clear') return cmdClear;
if(name==='compact'||name==='compress') return cmdCompact;
if(name==='model') return cmdModel;
if(name==='workspace') return cmdWorkspace;
if(name==='new') return cmdNew;
if(name==='usage') return cmdUsage;
if(name==='theme') return cmdTheme;
if(name==='skills') return cmdSkills;
if(name==='personality') return cmdPersonality;
if(_PASSTHROUGH.includes(name)) return cmdPassthrough;
// Fallback: passthrough unknown commands so new Hermes commands work without JS changes
return cmdPassthrough;
}
/**
* Fetch commands from Hermes COMMAND_REGISTRY and merge with WebUI-specific commands.
* Called once at boot time.
*/
async function loadCommands(){
try{
const data=await api('/api/commands');
if(data.error) throw new Error(data.error);
const cats=data.categories||{};
// Flatten all categories into COMMANDS
const merged=[];
for(const [catName,cmds] of Object.entries(cats)){
for(const c of cmds){
merged.push({name:c.name, desc:c.desc, arg:c.arg||'(none)',
aliases:c.aliases||[], fn:_fnFor(c.name)});
}
}
// ── Tier-2 Domain Agents (WebUI-specific, override API entries) ──
// Dedup: remove any API entries that would clash with Tier-2 agents
const _agentNames=['sunflower','lotus','forget-me-not','iris','ivy',
'dandelion','root','back','inbox'];
// Remove API entries for agent names (they may already be in the registry
// from the API if agents registered themselves as commands there)
const filtered=merged.filter(c=>!_agentNames.includes(c.name));
// Add Tier-2 agents (these override any API entries of the same name)
filtered.push(
{name:'sunflower', desc:'🌻 Finance, Wealth & Subscriptions', fn:cmdAgent, arg:'message'},
{name:'lotus', desc:'🪷 Health, Fitness & Recovery', fn:cmdAgent, arg:'message'},
{name:'forget-me-not', desc:'🌼 Calendar, Time & Social', fn:cmdAgent, arg:'message'},
{name:'iris', desc:'⚜️ Career, Learning & Focus', fn:cmdAgent, arg:'message'},
{name:'ivy', desc:'🌿 Smart Home & Environment', fn:cmdAgent, arg:'message'},
{name:'dandelion', desc:'🛡 Communication Triage & Gatekeeping',fn:cmdAgent, arg:'message'},
{name:'root', desc:'🌳 DevOps, Logs & System Health', fn:cmdAgent, arg:'message'},
{name:'back', desc:'🌹 Return to Rose (orchestrator)', fn:cmdAgent, arg:'message'},
);
COMMANDS=filtered;
}catch(e){
console.warn('[commands] Failed to load from API, using fallback:',e.message);
// Fallback: empty — user can still type commands manually
COMMANDS=[];
}
}
function parseCommand(text){
if(!text.startsWith('/'))return null;
const parts=text.slice(1).split(/\s+/);
const name=parts[0].toLowerCase();
const args=parts.slice(1).join(' ').trim();
return {name,args};
}
function executeCommand(text){
const parsed=parseCommand(text);
if(!parsed)return false;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return false;
cmd.fn(parsed.args);
return true;
}
function getMatchingCommands(prefix){
const q=prefix.toLowerCase();
return COMMANDS.filter(c=>{
if(c.name.startsWith(q)) return true;
// Also match aliases
if(c.aliases&&c.aliases.some(a=>a.startsWith(q))) return true;
return false;
});
}
// ── Generic passthrough: send command text directly to agent ────────────
function cmdPassthrough(args){
const parsed=parseCommand($('msg').value);
if(!parsed)return;
// Forward the raw command to the agent as a regular message
$('msg').value=$('msg').value; // keep as-is
send();
}
// ── Command handlers ────────────────────────────────────────────────────
function cmdHelp(){
// Infer categories from command names (backwards-compatible with hardcoded categories)
const categories={'Session':[],'Configuration':[],'Tools & Skills':[],'Info':[],'Agents':[]};
COMMANDS.forEach(c=>{
let cat='Info';
if(['new','clear','compact','compress','retry','undo','title','branch',
'stop','background','btw','queue','status','profile','resume',
'snapshot','rollback'].includes(c.name)) cat='Session';
else if(['model','provider','personality','workspace','theme','yolo',
'reasoning','fast','voice','reload','reload-mcp'].includes(c.name)) cat='Configuration';
else if(['skills','cron','browser','plugins'].includes(c.name)) cat='Tools & Skills';
else if(['sunflower','lotus','forget-me-not','iris','ivy','dandelion',
'root','back','inbox'].includes(c.name)) cat='Agents';
if(!categories[cat])categories[cat]=[];
categories[cat].push(c);
});
const lines=[];
for(const [cat,cmds] of Object.entries(categories)){
if(!cmds.length)continue;
lines.push(`\n**${cat}**`);
cmds.forEach(c=>{
const usage=c.arg&&c.arg!=='(none)'?` <${c.arg}>`:'';
lines.push(` /${c.name}${usage}${c.desc}`);
});
}
const msg={role:'assistant',content:'Available commands:\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast('Type / to see commands');
}
function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
renderMessages();
$('emptyState').style.display='';
showToast(t('conversation_cleared'));
}
async function cmdModel(args){
if(!args){showToast('Usage: /model <model_name>');return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
let match=null;
for(const opt of sel.options){
if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){
match=opt.value;break;
}
}
if(!match){showToast('No model matching "'+args+'"');return;}
sel.value=match;
await sel.onchange();
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast('Usage: /workspace <name>');return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast('No workspace matching "'+args+'"');return;}
if(typeof switchToWorkspace==='function') await switchToWorkspace(ws.path, ws.name||ws.path);
else showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdNew(){
await newSession();
await renderSessionList();
$('msg').focus();
showToast(t('new_session'));
}
function cmdCompact(){
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast(t('compressing'));
}
async function cmdUsage(){
const next=!window._showTokenUsage;
window._showTokenUsage=next;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
}catch(e){}
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){
showToast('Themes: '+themes.join(' | '));
return;
}
const themeName=args.toLowerCase();
localStorage.setItem('hermes-theme',themeName);
_applyTheme(themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=themeName;
showToast(t('theme_set')+themeName);
}
async function cmdSkills(args){
try{
const data = await api('/api/skills');
let skills = data.skills || [];
if(args){
const q = args.toLowerCase();
skills = skills.filter(s =>
(s.name||'').toLowerCase().includes(q) ||
(s.description||'').toLowerCase().includes(q) ||
(s.category||'').toLowerCase().includes(q)
);
}
if(!skills.length){
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? `${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
lines.push(` \`${s.name}\`${desc}`);
});
lines.push('');
}
const header = args
? `Skills matching "${args}" (${skills.length}):\n\n`
: `Available skills (${skills.length}):\n\n`;
S.messages.push({role:'assistant', content: header + lines.join('\n')});
renderMessages();
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+'\n\nSwitch with: /personality <name>'});
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
// ── Tier-2 Agent Command Handler ────────────────────────────────────────
const AGENT_INFO={
'sunflower': {emoji:'🌻', name:'Sunflower', file:'sunflower/soul.md', domain:'Finance, Wealth & Subscriptions'},
'lotus': {emoji:'🪷', name:'Lotus', file:'lotus/soul.md', domain:'Health, Fitness & Recovery'},
'forget-me-not': {emoji:'🌼', name:'Forget-me-not',file:'forget-me-not/soul.md', domain:'Calendar, Time & Social'},
'iris': {emoji:'⚜️', name:'Iris', file:'iris/soul.md', domain:'Career, Learning & Focus'},
'ivy': {emoji:'🌿', name:'Ivy', file:'ivy/soul.md', domain:'Smart Home & Environment'},
'dandelion': {emoji:'🛡', name:'Dandelion', file:'dandelion/soul.md', domain:'Communication Triage & Gatekeeping'},
'root': {emoji:'🌳', name:'Root', file:'root/soul.md', domain:'DevOps, Logs & System Health'},
'back': {emoji:'🌹', name:'Rose', file:'rose/soul.md', domain:'Orchestrator (return from agent)'},
};
function cmdAgent(args){
const parsed=parseCommand($('msg').value);
if(!parsed)return;
const agentKey=parsed.name;
const info=AGENT_INFO[agentKey];
if(!info){showToast('Unknown agent: '+agentKey);return;}
const userMsg=args||'';
const contextMsg=`[Agent Switch: ${info.emoji} ${info.name}]\nLoad ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg?'\n\nUser message: '+userMsg:''}`;
$('msg').value=contextMsg;
send();
}
// ── Autocomplete dropdown ───────────────────────────────────────────────
let _cmdSelectedIdx=-1;
function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
el.dataset.idx=i;
const usage=c.arg&&c.arg!=='(none)'?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.onmousedown=(e)=>{
e.preventDefault();
$('msg').value='/'+c.name+(c.arg&&c.arg!=='(none)'?' ':'');
hideCmdDropdown();
$('msg').focus();
};
dd.appendChild(el);
}
dd.classList.add('open');
}
function hideCmdDropdown(){
const dd=$('cmdDropdown');
if(dd)dd.classList.remove('open');
_cmdSelectedIdx=-1;
}
function navigateCmdDropdown(dir){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(!items.length)return;
items.forEach(el=>el.classList.remove('selected'));
_cmdSelectedIdx+=dir;
if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1;
if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0;
items[_cmdSelectedIdx].classList.add('selected');
}
function selectCmdDropdownItem(){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
} else if(items.length===1){
items[0].onmousedown({preventDefault:()=>{}});
}
hideCmdDropdown();
}

BIN
static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

20
static/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#1a1a1a"/>
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#g)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

2409
static/i18n.js Normal file

File diff suppressed because it is too large Load Diff

77
static/icons.js Normal file
View File

@@ -0,0 +1,77 @@
// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ──────────
// All icons are 24×24 viewBox, stroke-based, currentColor.
// Usage: li('folder') → returns a ready-to-embed SVG string
// The returned SVG uses display:inline-block + vertical-align so it sits
// neatly beside text in both HTML templates and innerHTML assignments.
const LI_PATHS = {
// Navigation tabs
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
// Editing / actions
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>',
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
'pause': '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
// Tool icons
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
'paperclip': '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.48"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'rotate-ccw': '<path d="M3 2v6h6"/><path d="M3 8a9 9 0 1 0 2.64-4.36L3 8"/>',
'user': '<path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/>',
// File-type icons
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
// Suggestion buttons
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
};
/**
* Returns a Lucide SVG string for the given icon name.
* @param {string} name key in LI_PATHS (e.g. 'folder', 'trash-2')
* @param {number} size width/height in px (default 16)
* @returns {string} SVG element string ready for innerHTML
*/
function li(name, size = 16) {
const p = LI_PATHS[name];
if (!p) { console.warn('li(): unknown icon', name); return ''; }
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
+ `stroke-linejoin="round" aria-hidden="true" `
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
}

697
static/index.html Normal file
View File

@@ -0,0 +1,697 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<link rel="stylesheet" href="/static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab" data-panel="missioncontrol" data-label="MC" onclick="switchPanel('missioncontrol')" title="Mission Control" data-i18n-title="tab_mc"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></button>
<button class="nav-tab" data-panel="agents" data-label="Agents" onclick="switchPanel('agents')" title="Rose + Tier-2 Agents" data-i18n-title="tab_agents"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">Cmd+K</span>
</button>
</div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
</div>
<!-- Create job form (hidden by default) -->
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
<option value="local">Local (save output only)</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<div class="skill-picker-wrap" style="margin-bottom:8px">
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
</div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
</div>
<!-- Skill create/edit form (hidden by default) -->
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Memory panel -->
<div class="panel-view" id="panelMemory">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
</div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
<!-- Memory edit form (hidden by default) -->
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
</div>
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
</div>
<!-- Todo panel -->
<div class="panel-view" id="panelTodos">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Mission Control panel -->
<div class="panel-view" id="panelMissioncontrol">
<!-- Header -->
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:16px">🎯</span>
<span style="font-size:14px;font-weight:700;color:var(--text)">Mission Control</span>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span id="mcHealthBadge" style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:12px;background:rgba(0,0,0,.06)"></span>
<button onclick="refreshMC()" title="Refresh" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px"></button>
</div>
</div>
</div>
<!-- Stats Cards -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px 16px;flex-shrink:0">
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Tasks</div>
<div style="font-size:18px;font-weight:700;color:var(--blue)" id="mcTasksCount"></div>
<div style="font-size:9px;color:var(--muted)" id="mcTasksLabel">loading...</div>
</div>
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Priorities</div>
<div style="font-size:18px;font-weight:700;color:var(--gold)" id="mcPrioritiesCount"></div>
<div style="font-size:9px;color:var(--muted)" id="mcPrioritiesLabel">loading...</div>
</div>
</div>
<!-- Progress Bar -->
<div style="padding:0 16px 10px;flex-shrink:0">
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--muted);margin-bottom:4px">
<span>Progress</span><span id="mcProgressPct">0%</span>
</div>
<div style="background:var(--border);border-radius:6px;height:8px;overflow:hidden">
<div id="mcProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--blue),var(--accent));border-radius:6px;transition:width .4s ease"></div>
</div>
</div>
<!-- Add Task -->
<div style="padding:0 16px 10px;flex-shrink:0">
<div style="font-size:10px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">New Task</div>
<input id="mcNewTaskTitle" placeholder="What needs to be done?" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text);outline:none;margin-bottom:6px" onkeydown="if(event.key==='Enter')createMCTask()">
<div style="display:grid;grid-template-columns:1fr 1fr 80px;gap:6px">
<select id="mcNewTaskPriority" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
<option value="1">🔴 Critical</option>
<option value="2" selected>🟠 High</option>
<option value="3">🟡 Medium</option>
<option value="4">🟢 Low</option>
</select>
<select id="mcNewTaskStatus" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
<option value="backlog" selected>○ Backlog</option>
<option value="progress">◐ In Progress</option>
</select>
<button onclick="createMCTask()" style="background:var(--accent);border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:600;color:#fff;cursor:pointer">Add</button>
</div>
</div>
<!-- Priority Filter -->
<div style="padding:0 16px 6px;flex-shrink:0">
<div style="display:flex;gap:4px;flex-wrap:wrap" id="mcPriorityFilters"></div>
</div>
<!-- Tasks List -->
<div style="flex:1;overflow-y:auto;padding:0 16px" id="mcTasksList"></div>
<!-- Feed -->
<div style="padding:8px 16px 4px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Recent Activity</div>
</div>
<div id="mcFeed" style="flex-shrink:0;max-height:100px;overflow-y:auto;padding:0 16px 12px;font-size:10px;color:var(--muted)"></div>
</div>
<!-- Agents panel (Rose + Tier-2) -->
<div class="panel-view" id="panelAgents">
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:16px">🌹</span>
<span style="font-size:14px;font-weight:700;color:var(--text)">Rose Agent Network</span>
</div>
<button onclick="refreshAgents()" title="Refresh" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px"></button>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Rose + 7 Tier-2 Domain Agents</div>
</div>
<!-- Agents list -->
<div style="flex:1;overflow-y:auto;padding:10px 16px" id="agentsList">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Loading...</div>
</div>
<!-- Agent inbox slide-in panel -->
<div id="agentInbox" style="display:none;position:absolute;top:0;right:0;bottom:0;width:320px;background:var(--surface);border-left:1px solid var(--border);z-index:100;overflow-y:auto;padding:16px;box-shadow:-4px 0 20px rgba(0,0,0,.3)"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<div class="sidebar-bottom">
<button class="hermes-launch-btn" id="btnHermesPanel" onclick="toggleSettings()" title="Open Hermes control center">
<span class="hermes-launch-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="hermes-gold-sidebar" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold-sidebar)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg></span>
<span class="hermes-launch-copy">
<span class="hermes-launch-title">Hermes WebUI</span>
<span class="hermes-launch-meta">Preferences, imports, exports</span>
</span>
<span class="hermes-launch-chevron" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
</div>
<div class="resize-handle" id="sidebarResize"></div>
</aside>
<main class="main">
<div class="topbar">
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
<div class="topbar-chips">
<button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span class="workspace-toggle-label">Files</span></button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="emptyState">
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
<defs>
<linearGradient id="hermes-gold" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg></div>
<h2 data-i18n="empty_title">What can I help with?</h2>
<p data-i18n="empty_subtitle">Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion" data-msg="What files are in this workspace?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <span data-i18n="suggest_files">What files are in this workspace?</span></button>
<button class="suggestion" data-msg="What's on my schedule today?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg> <span data-i18n="suggest_schedule">What's on my schedule today?</span></button>
<button class="suggestion" data-msg="Help me plan a small project."><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> <span data-i18n="suggest_plan">Help me plan a small project.</span></button>
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
<div class="tool-cards-toggle" id="toolCardToggleBtn" style="margin:4px 0 2px 40px;display:flex;gap:8px">
<button onclick="toggleShowAllTools()" id="btnShowAllTools" data-i18n="show_all_tools">Show all tools</button>
</div>
<button class="scroll-bottom-fab" id="scrollBottomFab" onclick="scrollToBottom()" title="Scroll to bottom"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<div class="update-banner" id="updateBanner">
<span id="updateMsg"></span>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="update-btn" onclick="dismissUpdate()">Later</button>
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
</div>
</div>
<div class="reconnect-banner" id="reconnectBanner">
<span id="reconnectMsg"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Drop files to upload to workspace
</div>
<div class="attach-tray" id="attachTray"></div>
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<button class="icon-btn mic-btn" id="btnMic" title="Voice input" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="1" width="6" height="12" rx="3"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
<div class="composer-divider" aria-hidden="true"></div>
<div id="profileChipWrap" class="composer-profile-wrap">
<button class="composer-profile-chip profile-chip" id="profileChip" type="button" onclick="toggleProfileDropdown()" title="Switch profile">
<span class="composer-profile-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
<span class="composer-profile-label" id="profileChipLabel">default</span>
<span class="composer-profile-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-ws-wrap">
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel">Model</span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
<optgroup label="OpenAI">
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="openai/o3">o3</option>
<option value="openai/o4-mini">o4-mini</option>
</optgroup>
<optgroup label="Anthropic">
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
</optgroup>
<optgroup label="Other">
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
</optgroup>
</select>
</div>
</div>
<div class="composer-right">
<span class="composer-status" id="composerStatus" style="display:none"></span>
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
<button class="ctx-indicator" id="ctxIndicator" type="button" aria-label="Context window usage" aria-describedby="ctxTooltip">
<span class="ctx-ring">
<svg class="ctx-ring-svg" viewBox="0 0 24 24" aria-hidden="true">
<circle class="ctx-ring-track" cx="12" cy="12" r="9.75"></circle>
<circle class="ctx-ring-value" id="ctxRingValue" cx="12" cy="12" r="9.75"></circle>
</svg>
<span class="ctx-ring-center" id="ctxPercent">0</span>
</span>
</button>
<div class="ctx-tooltip" id="ctxTooltip" role="tooltip" aria-hidden="true">
<div class="ctx-tooltip-title">Context window</div>
<div class="ctx-tooltip-line" id="ctxTooltipUsage"></div>
<div class="ctx-tooltip-line" id="ctxTooltipTokens"></div>
<div class="ctx-tooltip-line" id="ctxTooltipThreshold"></div>
<div class="ctx-tooltip-line" id="ctxTooltipCost" style="display:none"></div>
</div>
</div>
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
</button>
<button class="send-btn" id="btnSend" title="Send message" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>
</div>
<div class="profile-dropdown" id="profileDropdown"></div>
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
<div class="model-dropdown" id="composerModelDropdown"></div>
</div>
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
</div>
</div>
</main>
<aside class="rightpanel">
<div class="resize-handle" id="rightpanelResize"></div>
<div class="panel-header">
<span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnCollapseWorkspacePanel" title="Hide workspace panel" onclick="toggleWorkspacePanel(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg></button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn" id="btnSearchFiles" title="Search files" onclick="toggleWsSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<button class="panel-icon-btn mobile-close-btn" onclick="handleWorkspaceClose()" title="Close" aria-label="Close workspace panel">×</button>
</div>
</div>
<div class="ws-search-wrap" id="wsSearchWrap">
<input type="text" id="wsSearchInput" placeholder="Files suchen..." oninput="filterWsFiles()">
<button class="ws-search-clear" id="wsSearchClear" onclick="clearWsSearch()">&times;</button>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
<span class="preview-badge" id="previewBadge"></span>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
</aside>
</div>
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
<div class="onboarding-card">
<div class="onboarding-shell">
<div class="onboarding-sidebar">
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
<div class="onboarding-steps" id="onboardingSteps"></div>
</div>
<div class="onboarding-main">
<div class="onboarding-status" id="onboardingNotice"></div>
<div class="onboarding-body" id="onboardingBody"></div>
<div class="onboarding-actions">
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
<button class="sm-btn" id="onboardingSkipBtn" onclick="skipOnboarding()" style="margin-right:auto;opacity:.7" data-i18n="onboarding_skip">Skip setup</button>
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
</div>
</div>
</div>
</div>
</div>
<div class="settings-overlay" id="settingsOverlay" style="display:none">
<div class="settings-panel">
<div class="settings-header">
<div class="settings-heading">
<div class="settings-kicker">Hermes WebUI</div>
<h3 style="margin:0;font-size:18px">Control Center</h3>
<div class="settings-subtitle">Preferences, conversation tools, and system controls.</div>
</div>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="settings-body">
<div class="settings-shell">
<div class="settings-tabs" role="tablist" aria-label="Hermes control center sections">
<button class="settings-tab active" id="settingsTabConversation" type="button" role="tab" aria-selected="true" aria-controls="settingsPaneConversation" onclick="switchSettingsSection('conversation')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="settings-tab-title">Conversation</span>
</button>
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span class="settings-tab-title">Preferences</span>
</button>
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span class="settings-tab-title">System</span>
</button>
<button class="settings-tab" id="settingsTabGateways" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneGateways" onclick="switchSettingsSection('gateways')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span class="settings-tab-title">Gateways</span>
</button>
</div>
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Conversation</div>
<div class="settings-section-meta" id="hermesSessionMeta">No active conversation selected.</div>
</div>
</div>
<div class="hermes-action-grid">
<button class="settings-action-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="settings-action-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
<button class="settings-action-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<button class="settings-action-btn danger" id="btnClearConvModal" onclick="clearConversation()" title="Clear all messages in this conversation"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 1 2 2 2v2"/></svg> Clear</button>
</div>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Preferences</div>
<div class="settings-section-meta">Defaults and UI behavior for Hermes Web UI.</div>
</div>
</div>
<div class="settings-field">
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="enter">Enter (Shift+Enter for newline)</option>
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="_applyTheme(this.value)">
<option value="system">System (auto)</option>
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sound">Notification sound</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsBubbleLayout" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_bubble_layout">Chat bubble layout</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_bubble_layout">Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_check_updates">Check for updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
<div class="settings-section-head">
<div>
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.48</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div>
<div class="settings-pane" id="settingsPaneGateways" role="tabpanel" aria-labelledby="settingsTabGateways">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Gateways</div>
<div class="settings-section-meta">Manage Hermes gateway connections (Telegram, OpenClaw, etc.)</div>
</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Active connections and gateway control.</div>
<div id="gatewaysPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeMobileSidebar()"></div>
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
<div class="app-dialog-header">
<div class="app-dialog-title" id="appDialogTitle">Confirm action</div>
<button class="app-dialog-close" id="appDialogClose" type="button" aria-label="Close dialog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="app-dialog-desc" id="appDialogDesc"></div>
<input class="app-dialog-input" id="appDialogInput" type="text" style="display:none">
<div class="app-dialog-actions">
<button class="app-dialog-btn" id="appDialogCancel" type="button" data-i18n="cancel">Cancel</button>
<button class="app-dialog-btn confirm" id="appDialogConfirm" type="button">Confirm</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/onboarding.js"></script>
<script src="/static/boot.js"></script>
</body>
</html>

55
static/login.js Normal file
View File

@@ -0,0 +1,55 @@
/* Login page — external script, no inline handlers.
* Loaded by the /login route. Reads data attributes from the form for
* i18n strings so the server does not need to inject JS literals.
*/
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var input = document.getElementById('pw');
if (!form || !input) return;
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
function showErr(msg) {
var err = document.getElementById('err');
if (err) { err.textContent = msg; err.style.display = 'block'; }
}
function hideErr() {
var err = document.getElementById('err');
if (err) { err.style.display = 'none'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = './';
} else {
showErr(data.error || invalidPw);
}
} catch (ex) {
showErr(connFailed);
}
}
form.addEventListener('submit', doLogin);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doLogin(e);
}
});
});

1096
static/messages.js Normal file

File diff suppressed because it is too large Load Diff

390
static/onboarding.js Normal file
View File

@@ -0,0 +1,390 @@
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
function _getOnboardingSetupProviders(){
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
}
function _getOnboardingSetupProvider(id){
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
}
function _getOnboardingCurrentSetup(){
return (((ONBOARDING.status||{}).setup||{}).current)||{};
}
function _onboardingStepMeta(key){
return ({
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
})[key];
}
function _renderOnboardingSteps(){
const wrap=$('onboardingSteps');
if(!wrap)return;
wrap.innerHTML='';
ONBOARDING.steps.forEach((key,idx)=>{
const meta=_onboardingStepMeta(key);
const item=document.createElement('div');
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
wrap.appendChild(item);
});
}
function _setOnboardingNotice(msg,kind='info'){
const el=$('onboardingNotice');
if(!el)return;
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
el.style.display='block';
el.className='onboarding-status '+kind;
el.textContent=msg;
}
function _getOnboardingWorkspaceChoices(){
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
}
function _getOnboardingProviderModelChoices(){
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
return provider?(provider.models||[]):[];
}
function _getOnboardingSelectedModel(){
return ONBOARDING.form.model||'';
}
function _renderOnboardingModelField(){
const choices=_getOnboardingProviderModelChoices();
if(ONBOARDING.form.provider==='custom'){
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
}
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
}
function _providerStatusLabel(system){
if(system.chat_ready) return t('onboarding_check_provider_ready');
if(system.provider_configured) return t('onboarding_check_provider_partial');
return t('onboarding_check_provider_pending');
}
function _renderOnboardingBody(){
const body=$('onboardingBody');
if(!body||!ONBOARDING.status)return;
const key=ONBOARDING.steps[ONBOARDING.step];
const system=ONBOARDING.status.system||{};
const settings=ONBOARDING.status.settings||{};
const setup=ONBOARDING.status.setup||{};
const nextBtn=$('onboardingNextBtn');
const backBtn=$('onboardingBackBtn');
if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none';
if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue');
if(key==='system'){
const hermesOk=system.hermes_found&&system.imports_ok;
const setupOk=!!system.chat_ready;
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
body.innerHTML=`
<div class="onboarding-panel-grid">
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
</div>
<div class="onboarding-copy">
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
<p>${esc(system.provider_note||'')}</p>
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?`${esc(system.current_model)}`:''}</p>`:''}
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
</div>`;
return;
}
if(key==='setup'){
const providers=_getOnboardingSetupProviders();
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
const showBaseUrl=provider&&provider.requires_base_url;
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
// OAuth provider path: configured via CLI, no API key input needed.
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
if(currentIsOauth){
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
const providerLabel=esc(currentProviderName);
if(isReady){
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-ready">
<div class="onboarding-oauth-icon">✓</div>
<div>
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
} else {
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">⚠</div>
<div>
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
}
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
if(key==='workspace'){
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)}${esc(ws.path)}</option>`).join('');
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_workspace_label')}</span>
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
</label>
<label class="onboarding-field">
<span>${t('onboarding_workspace_or_path')}</span>
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
</label>
${_renderOnboardingModelField()}`;
const wsSel=$('onboardingWorkspaceSelect');
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
const modelSel=$('onboardingModelSelect');
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
return;
}
if(key==='password'){
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
body.innerHTML=`
<label class="onboarding-field">
<span>${t('onboarding_password_label')}</span>
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
</label>
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
return;
}
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
body.innerHTML=`
<div class="onboarding-summary">
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
<div><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
</div>
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
}
function _getOnboardingPasswordSummaryKey(settings){
const hasExistingPassword=!!(settings&&settings.password_enabled);
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
}
function syncOnboardingWorkspaceSelect(value){
ONBOARDING.form.workspace=value;
const input=$('onboardingWorkspaceInput');
if(input) input.value=value;
}
function syncOnboardingProvider(value){
const provider=_getOnboardingSetupProvider(value);
ONBOARDING.form.provider=value;
if(provider){
if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){
ONBOARDING.form.model=provider.default_model||'';
}
if(provider.requires_base_url){
ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||'';
}else{
ONBOARDING.form.baseUrl=provider.default_base_url||'';
}
}
_renderOnboardingBody();
}
async function loadOnboardingWizard(){
try{
const status=await api('/api/onboarding/status');
ONBOARDING.status=status;
const current=((status.setup||{}).current)||{};
ONBOARDING.form.provider=current.provider||'openrouter';
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
ONBOARDING.form.password='';
ONBOARDING.form.apiKey='';
ONBOARDING.form.baseUrl=current.base_url||'';
ONBOARDING.active=!status.completed;
if(!ONBOARDING.active) return false;
$('onboardingOverlay').style.display='flex';
_renderOnboardingSteps();
_renderOnboardingBody();
return true;
}catch(e){
console.warn('onboarding status failed',e);
return false;
}
}
function prevOnboardingStep(){
if(ONBOARDING.step===0)return;
ONBOARDING.step--;
_renderOnboardingSteps();
_renderOnboardingBody();
}
async function _saveOnboardingProviderSetup(){
const provider=(ONBOARDING.form.provider||'').trim();
const model=(ONBOARDING.form.model||'').trim();
const apiKey=(ONBOARDING.form.apiKey||'').trim();
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
const current=_getOnboardingCurrentSetup();
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
// Skip the POST when nothing changed. We also skip when the provider is
// unsupported/OAuth-based and already working — chat_ready may be false for
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
// fully configured. Posting in that case would either be a no-op (the server
// just marks complete for unsupported providers) or could silently overwrite
// config.yaml if the user accidentally changed the provider dropdown.
const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth);
if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return;
const body={provider,model};
if(apiKey) body.api_key=apiKey;
if(baseUrl) body.base_url=baseUrl;
const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)});
ONBOARDING.status=status;
}
async function _saveOnboardingDefaults(){
const workspace=(ONBOARDING.form.workspace||'').trim();
const model=(ONBOARDING.form.model||'').trim();
const password=(ONBOARDING.form.password||'').trim();
if(!workspace) throw new Error(t('onboarding_error_choose_workspace'));
if(!model) throw new Error(t('onboarding_error_choose_model'));
const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace);
if(!known){
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
}
const body={default_workspace:workspace,default_model:model};
if(password) body._set_password=password;
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
if(ONBOARDING.status){
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
}
localStorage.setItem('hermes-webui-model',model);
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
}
async function _finishOnboarding(){
await _saveOnboardingProviderSetup();
await _saveOnboardingDefaults();
const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'});
ONBOARDING.status=done;
ONBOARDING.active=false;
$('onboardingOverlay').style.display='none';
showToast(t('onboarding_complete'));
await loadWorkspaceList();
if(typeof renderSessionList==='function') await renderSessionList();
if(!S.session && typeof newSession==='function'){
await newSession(true);
await renderSessionList();
}
}
async function skipOnboarding(){
try{
// Mark onboarding completed server-side without changing any config
await api('/api/onboarding/complete',{method:'POST',body:'{}'});
ONBOARDING.active=false;
$('onboardingOverlay').style.display='none';
showToast(t('onboarding_skipped')||'Setup skipped');
}catch(e){
_setOnboardingNotice((e.message||String(e)),'warn');
}
}
async function nextOnboardingStep(){
try{
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){
ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim();
ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim();
ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim();
if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required'));
if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required'));
}
if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){
ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim();
ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim();
if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required'));
if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required'));
}
if(ONBOARDING.steps[ONBOARDING.step]==='password'){
ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim();
}
if(ONBOARDING.step===ONBOARDING.steps.length-1){
await _finishOnboarding();
return;
}
ONBOARDING.step++;
_renderOnboardingSteps();
_renderOnboardingBody();
}catch(e){
_setOnboardingNotice(e.message||String(e),'warn');
}
}

1911
static/panels.js Normal file

File diff suppressed because it is too large Load Diff

932
static/sessions.js Normal file
View File

@@ -0,0 +1,932 @@
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS={
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
archive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
};
async function newSession(flash){
updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
// Use profile default workspace for new sessions after a profile switch (one-shot),
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
// Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(S.session.session_id);
syncTopbar();renderMessages();loadDir('.');
// don't call renderSessionList here - callers do it when needed
}
async function loadSession(sid){
stopApprovalPolling();hideApprovalCard();
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
if(typeof hideClarifyCard==='function') hideClarifyCard();
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})};
localStorage.setItem('hermes-webui-session',S.session.session_id);
data.session.messages = (data.session.messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = (data.session.messages || []).some(m => {
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
});
const activeStreamId=data.session.active_stream_id||null;
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
if(stored){
INFLIGHT[sid]={
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[...(data.session.messages||[])],
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[...(data.session.pending_attachments||[])],
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
reattach:true,
};
}
}
if(INFLIGHT[sid]){
S.messages=INFLIGHT[sid].messages;
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
S.busy=true;
syncTopbar();renderMessages();appendThinking();loadDir('.');
clearLiveToolCards();
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
for(const tc of (S.toolCalls||[])){
if(tc&&tc.name) appendLiveToolCard(tc);
}
setBusy(true);setComposerStatus('');
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
S.activeStreamId=activeStreamId;
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
INFLIGHT[sid].reattach=false;
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
}
}else{
updateQueueBadge(sid);
S.messages=data.session.messages||[];
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
// Prefer reconstructing cards from per-message tool metadata when available.
// Fall back to persisted session summaries for older sessions that only
// saved session.tool_calls and bare role=tool results.
if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length){
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
}else{
S.toolCalls=[];
}
clearLiveToolCards();
if(activeStreamId){
S.busy=true;
S.activeStreamId=activeStreamId;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='inline-flex';
setStatus('');
setComposerStatus('');
syncTopbar();renderMessages();appendThinking();loadDir('.');
updateQueueBadge(sid);
startApprovalPolling(sid);
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
}else{
// Reset per-session visual state: the viewed session is idle even if another
// session's stream is still running in the background.
// We directly update the DOM instead of calling setBusy(false), because
// setBusy(false) drains the viewed session's queued follow-up turns.
S.busy=false;
S.activeStreamId=null;
updateSendBtn();
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
setStatus('');
setComposerStatus('');
updateQueueBadge(sid);
syncTopbar();renderMessages();highlightCode();loadDir('.');
}
}
// Sync context usage indicator from session data
const _s=S.session;
if(_s&&typeof _syncCtxIndicator==='function'){
const u=S.lastUsage||{};
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
_syncCtxIndicator({
input_tokens: _pick(u.input_tokens, _s.input_tokens),
output_tokens: _pick(u.output_tokens, _s.output_tokens),
estimated_cost: _pick(u.estimated_cost, _s.estimated_cost),
context_length: _pick(u.context_length, _s.context_length),
last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens),
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
});
}
}
let _allSessions = []; // cached for search filter
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
let _showArchived = false; // toggle to show archived sessions
let _allProjects = []; // cached project list
let _activeProject = null; // project_id filter (null = show all)
let _showAllProfiles = false; // false = filter to active profile only
let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
function closeSessionActionMenu(){
if(_sessionActionMenu){
_sessionActionMenu.remove();
_sessionActionMenu = null;
}
if(_sessionActionAnchor){
_sessionActionAnchor.classList.remove('active');
const row=_sessionActionAnchor.closest('.session-item');
if(row) row.classList.remove('menu-open');
_sessionActionAnchor = null;
}
_sessionActionSessionId = null;
}
function _positionSessionActionMenu(anchorEl){
if(!_sessionActionMenu || !anchorEl) return;
const rect=anchorEl.getBoundingClientRect();
const menuW=Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220));
let left=rect.right-menuW;
if(left<8) left=8;
if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
_sessionActionMenu.style.left=left+'px';
_sessionActionMenu.style.top='8px';
const menuH=_sessionActionMenu.offsetHeight || 0;
let top=rect.bottom+6;
if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){
top=rect.top-menuH-6;
}
if(top<8) top=8;
_sessionActionMenu.style.top=top+'px';
}
function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
const opt=document.createElement('button');
opt.type='button';
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
opt.innerHTML=
`<span class="ws-opt-action">`
+ `<span class="ws-opt-icon">${icon}</span>`
+ `<span class="session-action-copy">`
+ `<span class="ws-opt-name">${esc(label)}</span>`
+ (meta?`<span class="session-action-meta">${esc(meta)}</span>`:'')
+ `</span>`
+ `</span>`;
opt.onclick=async(e)=>{
e.preventDefault();
e.stopPropagation();
await onSelect();
};
return opt;
}
function _openSessionActionMenu(session, anchorEl){
if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){
closeSessionActionMenu();
return;
}
closeSessionActionMenu();
const menu=document.createElement('div');
menu.className='session-action-menu open';
menu.appendChild(_buildSessionAction(
session.pinned?'Unpin conversation':'Pin conversation',
session.pinned?'Remove from pinned':'Keep this conversation at the top',
session.pinned?ICONS.pin:ICONS.unpin,
async()=>{
closeSessionActionMenu();
const newPinned=!session.pinned;
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
session.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
}catch(err){showToast('Pin failed: '+err.message);}
},
session.pinned?'is-active':''
));
menu.appendChild(_buildSessionAction(
'Move to project',
session.project_id?'Change the project for this conversation':'Assign a project to this conversation',
ICONS.folder,
async()=>{
closeSessionActionMenu();
_showProjectPicker(session, anchorEl);
}
));
menu.appendChild(_buildSessionAction(
session.archived?'Restore conversation':'Archive conversation',
session.archived?'Bring this conversation back into the main list':'Hide this conversation until archived is shown',
session.archived?ICONS.unarchive:ICONS.archive,
async()=>{
closeSessionActionMenu();
try{
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})});
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await renderSessionList();
showToast(session.archived?'Session archived':'Session restored');
}catch(err){showToast('Archive failed: '+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Duplicate conversation',
'Create a copy with the same workspace and model',
ICONS.dup,
async()=>{
closeSessionActionMenu();
try{
const res=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:session.workspace,model:session.model})});
if(res.session){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:res.session.session_id,title:(session.title||'Untitled')+' (copy)'})});
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session duplicated');
}
}catch(err){showToast('Duplicate failed: '+err.message);}
}
));
menu.appendChild(_buildSessionAction(
'Delete conversation',
'Permanently remove this conversation',
ICONS.trash,
async()=>{
closeSessionActionMenu();
await deleteSession(session.session_id);
},
'danger'
));
document.body.appendChild(menu);
_sessionActionMenu = menu;
_sessionActionAnchor = anchorEl;
_sessionActionSessionId = session.session_id;
anchorEl.classList.add('active');
const row=anchorEl.closest('.session-item');
if(row) row.classList.add('menu-open');
_positionSessionActionMenu(anchorEl);
}
document.addEventListener('click',e=>{
if(!_sessionActionMenu) return;
if(_sessionActionMenu.contains(e.target)) return;
if(_sessionActionAnchor && _sessionActionAnchor.contains(e.target)) return;
closeSessionActionMenu();
});
document.addEventListener('scroll',e=>{
if(!_sessionActionMenu) return;
if(_sessionActionMenu.contains(e.target)) return;
closeSessionActionMenu();
}, true);
document.addEventListener('keydown',e=>{
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu();
});
window.addEventListener('resize',()=>{
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
});
async function renderSessionList(){
try{
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
const [sessData, projData] = await Promise.all([
api('/api/sessions'),
api('/api/projects'),
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
// ── Gateway session SSE (real-time sync for agent sessions) ──
let _gatewaySSE = null;
function startGatewaySSE(){
stopGatewaySSE();
if(!window._showCliSessions) return;
try{
_gatewaySSE = new EventSource('api/sessions/gateway/stream');
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
try{
const data = JSON.parse(ev.data);
if(data.sessions){
renderSessionList(); // re-fetch and re-render
// If the active session received new gateway messages, refresh the conversation view.
// S.busy check prevents stomping on an in-progress WebUI response.
// is_cli_session check ensures we only poll import_cli for CLI-originated sessions.
if(S.session && !S.busy && S.session.is_cli_session){
const changedIds = new Set((data.sessions||[]).map(s=>s.session_id));
if(changedIds.has(S.session.session_id)){
// Capture active session ID before async fetch — race guard.
// If the user switches sessions while the fetch is in-flight, discard the result.
const activeSid = S.session.session_id;
api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:activeSid})})
.then(res=>{
if(!S.session || S.session.session_id !== activeSid) return;
if(res && res.session && Array.isArray(res.session.messages)){
const prev = S.messages.length;
S.messages = res.session.messages.filter(m=>m&&m.role);
if(S.messages.length !== prev){
renderMessages();
if(typeof highlightCode==='function') highlightCode();
}
}
})
.catch(()=>{ /* ignore — next poll will retry */ });
}
}
}
}catch(e){ /* ignore parse errors */ }
});
_gatewaySSE.onerror = () => {
// EventSource auto-reconnects; no action needed
};
}catch(e){ /* SSE not available */ }
}
function stopGatewaySSE(){
if(_gatewaySSE){
_gatewaySSE.close();
_gatewaySSE = null;
}
}
let _searchDebounceTimer = null;
let _contentSearchResults = []; // results from /api/sessions/search content scan
function filterSessions(){
// Immediate client-side title filter (no flicker)
renderSessionListFromCache();
// Debounced content search via API for message text
const q = ($('sessionSearch').value || '').trim();
clearTimeout(_searchDebounceTimer);
if (!q) { _contentSearchResults = []; return; }
_searchDebounceTimer = setTimeout(async () => {
try {
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`);
const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id));
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id));
renderSessionListFromCache();
} catch(e) { /* ignore */ }
}, 350);
}
function _sessionTimestampMs(session) {
const raw = Number(session && (session.updated_at || session.created_at || 0));
return Number.isFinite(raw) ? raw * 1000 : 0;
}
function _localDayOrdinal(timestampMs) {
const date = new Date(timestampMs);
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
}
function _sessionCalendarBoundaries(nowMs = Date.now()) {
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const startOfWeek = new Date(startOfToday);
startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7));
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
return {
startOfToday: startOfToday.getTime(),
startOfYesterday: startOfYesterday.getTime(),
startOfWeek: startOfWeek.getTime(),
startOfLastWeek: startOfLastWeek.getTime(),
};
}
function _formatSessionDate(timestampMs, nowMs = Date.now()) {
const date = new Date(timestampMs);
const now = new Date(nowMs);
const options = {month:'short', day:'numeric'};
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
return date.toLocaleDateString(undefined, options);
}
function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
if (!timestampMs) return t('session_time_unknown');
const diffMs = Math.max(0, nowMs - timestampMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
if (timestampMs >= startOfToday) {
if (diffMs < minute) return t('session_time_just_now');
if (diffMs < hour) {
const minutes = Math.floor(diffMs / minute);
return t('session_time_minutes_ago', minutes);
}
const hours = Math.floor(diffMs / hour);
return t('session_time_hours_ago', hours);
}
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
return _formatSessionDate(timestampMs, nowMs);
}
function _sessionTimeBucketLabel(timestampMs, nowMs = Date.now()) {
if (!timestampMs) return t('session_time_bucket_older');
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
if (timestampMs >= startOfToday) return t('session_time_bucket_today');
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
if (timestampMs >= startOfWeek) return t('session_time_bucket_this_week');
if (timestampMs >= startOfLastWeek) return t('session_time_bucket_last_week');
return t('session_time_bucket_older');
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
closeSessionActionMenu();
const q=($('sessionSearch').value||'').toLowerCase();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
// Merge content matches (deduped): content matches appended after title matches
const titleIds=new Set(titleMatches.map(s=>s.session_id));
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
// Filter by active profile (unless "All profiles" is toggled on)
// Server backfills profile='default' for legacy sessions, so every session has a profile.
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
// Filter by active project
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
// Filter archived unless toggle is on
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
// Project filter bar (only when projects exist)
if(_allProjects.length>0){
const bar=document.createElement('div');
bar.className='project-bar';
// "All" chip
const allChip=document.createElement('span');
allChip.className='project-chip'+(!_activeProject?' active':'');
allChip.textContent='All';
allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};
bar.appendChild(allChip);
// Project chips
for(const p of _allProjects){
const chip=document.createElement('span');
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.background=p.color;
chip.appendChild(dot);
}
const nameSpan=document.createElement('span');
nameSpan.textContent=p.name;
chip.appendChild(nameSpan);
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
bar.appendChild(chip);
}
// Create button
const addBtn=document.createElement('button');
addBtn.className='project-create-btn';
addBtn.textContent='+';
addBtn.title='New project';
addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);};
bar.appendChild(addBtn);
list.appendChild(bar);
}
// Profile filter toggle (show sessions from other profiles)
const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length;
if(otherProfileCount>0&&!_showAllProfiles){
const pfToggle=document.createElement('div');
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();};
list.appendChild(pfToggle);
} else if(_showAllProfiles&&otherProfileCount>0){
const pfToggle=document.createElement('div');
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
pfToggle.textContent='Show active profile only';
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();};
list.appendChild(pfToggle);
}
// Show/hide archived toggle if there are archived sessions
if(archivedCount>0){
const toggle=document.createElement('div');
toggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
toggle.textContent=_showArchived?'Hide archived':'Show '+archivedCount+' archived';
toggle.onclick=()=>{_showArchived=!_showArchived;renderSessionListFromCache();};
list.appendChild(toggle);
}
// Empty state for active project filter
if(_activeProject&&sessions.length===0){
const empty=document.createElement('div');
empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;';
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
// Separate pinned from unpinned
const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=orderedSessions.filter(s=>!s.pinned);
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
const now=Date.now();
// Collapse state persisted in localStorage
let _groupCollapsed={};
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
// Group sessions by date
const groups=[];
let curLabel=null,curItems=[];
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
for(const s of unpinned){
const ts=_sessionTimestampMs(s);
const label=_sessionTimeBucketLabel(ts, now);
if(label!==curLabel){
if(curItems.length) groups.push({label:curLabel,items:curItems});
curLabel=label;curItems=[s];
} else { curItems.push(s); }
}
if(curItems.length) groups.push({label:curLabel,items:curItems});
// Render groups with collapsible headers
for(const g of groups){
const wrapper=document.createElement('div');
wrapper.className='session-date-group';
const hdr=document.createElement('div');
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
const caret=document.createElement('span');
caret.className='session-date-caret';
caret.textContent='\u25B8'; // right-pointing triangle
const label=document.createElement('span');
label.textContent=g.label;
hdr.appendChild(caret);hdr.appendChild(label);
const body=document.createElement('div');
body.className='session-date-body';
if(_groupCollapsed[g.label]){body.style.display='none';caret.classList.add('collapsed');}
hdr.onclick=()=>{
const isCollapsed=body.style.display==='none';
body.style.display=isCollapsed?'':'none';
caret.classList.toggle('collapsed',!isCollapsed);
_groupCollapsed[g.label]=!isCollapsed;
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s)); }
wrapper.appendChild(body);
list.appendChild(wrapper);
}
// ── Render session items (extracted for group body use) ──
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s){
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
let cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
// Guard: system prompt content must never surface as a visible session title
if(cleanTitle.startsWith('[SYSTEM:')){
cleanTitle='Session';
}
const sessionText=document.createElement('div');
sessionText.className='session-text';
const titleRow=document.createElement('div');
titleRow.className='session-title-row';
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s);
titleRow.appendChild(title);
sessionText.appendChild(titleRow);
// Append tag chips after the title text
for(const tag of tags){
const chip=document.createElement('span');
chip.className='session-tag';
chip.textContent=tag;
chip.title='Click to filter by '+tag;
chip.onclick=(e)=>{
e.stopPropagation();
const searchBox=$('sessionSearch');
if(searchBox){searchBox.value=tag;filterSessions();}
};
title.appendChild(chip);
}
// Rename: called directly when we confirm it's a double-click
const startRename=()=>{
closeSessionActionMenu();
_renamingSid = s.session_id;
const inp=document.createElement('input');
inp.className='session-title-input';
inp.value=s.title||'Untitled';
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
inp.addEventListener(ev, e2=>e2.stopPropagation())
);
const finish=async(save)=>{
_renamingSid = null;
if(save){
const newTitle=inp.value.trim()||'Untitled';
title.textContent=newTitle;
s.title=newTitle;
if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();}
try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});}
catch(err){setStatus('Rename failed: '+err.message);}
}
inp.replaceWith(title);
// Allow list re-renders again after a short delay
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){
if(e2.isComposing){return;}
e2.preventDefault();
e2.stopPropagation();
finish(true);
}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); };
title.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
// Pin indicator (inline, only when pinned — no space reserved otherwise)
if(s.pinned){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
el.appendChild(pinInd);
}
// Project indicator: colored dot appended after the title
if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id);
if(proj){
const dot=document.createElement('span');
dot.className='session-project-dot';
dot.style.background=proj.color||'var(--blue)';
dot.title=proj.name;
title.appendChild(dot);
}
}
el.appendChild(sessionText);
// Single trigger button that opens a shared dropdown menu
const actions=document.createElement('div');
actions.className='session-actions';
const menuBtn=document.createElement('button');
menuBtn.type='button';
menuBtn.className='session-actions-trigger';
menuBtn.title='Conversation actions';
menuBtn.setAttribute('aria-haspopup','menu');
menuBtn.setAttribute('aria-label','Conversation actions');
menuBtn.innerHTML=ICONS.more;
menuBtn.onclick=(e)=>{
e.stopPropagation();
e.preventDefault();
_openSessionActionMenu(s, menuBtn);
};
actions.appendChild(menuBtn);
el.appendChild(actions);
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
// which would re-render the list and destroy the dblclick target before it fires.
let _clickTimer=null;
el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active
if(actions.contains(e.target)) return;
clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{
_clickTimer=null;
if(_renamingSid) return;
// For CLI sessions, import into WebUI store first (idempotent)
if(s.is_cli_session){
try{
await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})});
}catch(e){ /* import failed -- fall through to read-only view */ }
}
await loadSession(s.session_id);renderSessionListFromCache();
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}, 220);
};
el.ondblclick=async(e)=>{
e.stopPropagation();
e.preventDefault();
clearTimeout(_clickTimer); // cancel the pending single-click navigation
_clickTimer=null;
startRename();
};
return el;
}
}
async function deleteSession(sid){
const ok=await showConfirmDialog({
message:'Delete this conversation?',
confirmLabel:t('delete_title'),
danger:true
});
if(!ok)return;
try{
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
if(S.session&&S.session.session_id===sid){
S.session=null;S.messages=[];S.entries=[];
localStorage.removeItem('hermes-webui-session');
// load the most recent remaining session, or show blank if none left
const remaining=await api('/api/sessions');
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
$('topbarTitle').textContent=window._botName||'Hermes';
$('topbarMeta').textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';
$('fileTree').innerHTML='';
}
}
showToast('Conversation deleted');
await renderSessionList();
}
// ── Project helpers ─────────────────────────────────────────────────────
const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6'];
function _showProjectPicker(session, anchorEl){
// Close any existing picker
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
const picker=document.createElement('div');
picker.className='project-picker';
// "No project" option
const none=document.createElement('div');
none.className='project-picker-item'+(!session.project_id?' active':'');
none.textContent='No project';
none.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
session.project_id=null;
renderSessionListFromCache();
showToast('Removed from project');
};
picker.appendChild(none);
// Project options
for(const p of _allProjects){
const item=document.createElement('div');
item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
if(p.color){
const dot=document.createElement('span');
dot.className='color-dot';
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';
item.appendChild(dot);
}
const name=document.createElement('span');
name.textContent=p.name;
item.appendChild(name);
item.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
session.project_id=p.project_id;
renderSessionListFromCache();
showToast('Moved to '+p.name);
};
picker.appendChild(item);
}
// "+ New project" shortcut at the bottom
const createItem=document.createElement('div');
createItem.className='project-picker-item project-picker-create';
createItem.textContent='+ New project';
createItem.onclick=async()=>{
picker.remove();
document.removeEventListener('click',close);
const name=await showPromptDialog({
message:t('project_name_prompt'),
confirmLabel:t('create'),
placeholder:'Project name'
});
if(!name||!name.trim()) return;
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
if(res.project){
_allProjects.push(res.project);
// Now move session into it
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})});
session.project_id=res.project.project_id;
await renderSessionList();
showToast('Created "'+res.project.name+'" and moved session');
}
};
picker.appendChild(createItem);
// Append to body and position using getBoundingClientRect so it isn't clipped
// by overflow:hidden on .session-item ancestors
document.body.appendChild(picker);
const rect=anchorEl.getBoundingClientRect();
picker.style.position='fixed';
picker.style.zIndex='999';
// Prefer opening below; flip above if too close to bottom of viewport
const spaceBelow=window.innerHeight-rect.bottom;
if(spaceBelow<160&&rect.top>160){
picker.style.bottom=(window.innerHeight-rect.top+4)+'px';
picker.style.top='auto';
}else{
picker.style.top=(rect.bottom+4)+'px';
picker.style.bottom='auto';
}
// Align right edge of picker with right edge of button; keep within viewport
const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160));
let left=rect.right-pickerW;
if(left<8) left=8;
picker.style.left=left+'px';
// Close on outside click
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
setTimeout(()=>document.addEventListener('click',close),0);
}
function _startProjectCreate(bar, addBtn){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.placeholder='Project name';
const finish=async(save)=>{
if(save&&inp.value.trim()){
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
await renderSessionList();
showToast('Project created');
}else{
inp.replaceWith(addBtn);
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){
if(e.isComposing){return;}
e.preventDefault();
finish(true);
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
addBtn.replaceWith(inp);
setTimeout(()=>inp.focus(),10);
}
function _startProjectRename(proj, chip){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.value=proj.name;
const finish=async(save)=>{
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
await renderSessionList();
showToast('Project renamed');
}else{
renderSessionListFromCache();
}
};
inp.onkeydown=(e)=>{
if(e.key==='Enter'){
if(e.isComposing){return;}
e.preventDefault();
finish(true);
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
inp.onclick=(e)=>e.stopPropagation();
chip.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
}
async function _confirmDeleteProject(proj){
const ok=await showConfirmDialog({
message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
confirmLabel:t('delete_title'),
danger:true
});
if(!ok){return;}
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
if(_activeProject===proj.project_id) _activeProject=null;
await renderSessionList();
showToast('Project deleted');
}

1282
static/style.css Normal file

File diff suppressed because it is too large Load Diff

1921
static/ui.js Normal file

File diff suppressed because it is too large Load Diff

438
static/workspace.js Normal file
View File

@@ -0,0 +1,438 @@
async function api(path,opts={}){
// Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,location.href);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){
const text=await res.text();
// Parse JSON error body and surface the human-readable message,
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);}
catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;}
}
const ct=res.headers.get('content-type')||'';
return ct.includes('application/json')?res.json():res.text();
}
// Persist/restore expanded directory state per workspace in localStorage
function _wsExpandKey(){
const ws=S.session&&S.session.workspace;
return ws?'hermes-webui-expanded:'+ws:null;
}
function _saveExpandedDirs(){
const key=_wsExpandKey();if(!key)return;
try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){}
}
function _restoreExpandedDirs(){
const key=_wsExpandKey();
if(!key){S._expandedDirs=new Set();return;}
try{
const raw=localStorage.getItem(key);
S._expandedDirs=raw?new Set(JSON.parse(raw)):new Set();
}catch(e){S._expandedDirs=new Set();}
}
async function loadDir(path){
if(!S.session)return;
try{
if(!path||path==='.'){
S._dirCache={};
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
}
S.currentDir=path||'.';
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
// Pre-fetch contents of restored expanded dirs so they render without a second click
if(!path||path==='.'){
for(const dirPath of (S._expandedDirs||[])){
if(!S._dirCache[dirPath]){
try{
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
S._dirCache[dirPath]=dc.entries||[];
}catch(e2){S._dirCache[dirPath]=[];}
}
}
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
}
if(typeof clearPreview==='function'){
if(typeof _previewDirty!=='undefined'&&_previewDirty){
showConfirmDialog({title:t('unsaved_confirm'),message:'',confirmLabel:'Discard',danger:true,focusCancel:true}).then(ok=>{if(ok)clearPreview();});
}else{
clearPreview();
}
}
// Fetch git info for workspace root (non-blocking)
if(!path||path==='.') _refreshGitBadge();
}catch(e){console.warn('loadDir',e);}
}
async function _refreshGitBadge(){
const badge=$('gitBadge');
if(!badge||!S.session)return;
try{
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
if(data.git&&data.git.is_git){
const g=data.git;
let text=g.branch||'git';
if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta
if(g.behind>0) text+=` \u2193${g.behind}`;
if(g.ahead>0) text+=` \u2191${g.ahead}`;
badge.textContent=text;
badge.className='git-badge'+(g.dirty>0?' dirty':'');
badge.style.display='';
} else {
badge.style.display='none';
badge.textContent='';
}
}catch(e){badge.style.display='none';}
}
function navigateUp(){
if(!S.session||S.currentDir==='.')return;
const parts=S.currentDir.split('/');
parts.pop();
loadDir(parts.length?parts.join('/'):'.');
}
// File extension sets for preview routing (must match server-side sets)
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
'.exe','.dmg','.pkg','.deb','.rpm',
'.woff','.woff2','.ttf','.otf','.eot',
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
]);
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'image' | 'md'
$('previewCode').style.display = mode==='code' ? '' : 'none';
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
$('previewMd').style.display = mode==='md' ? '' : 'none';
$('previewEditArea').style.display = 'none'; // start in read-only
const badge=$('previewBadge');
badge.className='preview-badge '+mode;
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
_previewCurrentMode = mode;
_previewDirty = false;
updateEditBtn();
}
function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
btn.title = editing ? t('save_title') : t('edit_title');
btn.style.color = editing ? 'var(--blue)' : '';
if(_previewDirty) btn.innerHTML = '&#128190; Save*';
}
async function toggleEditMode(){
const editing = $('previewEditArea').style.display!=='none';
if(editing){
// Save
if(!S.session||!_previewCurrentPath)return;
const content=$('previewEditArea').value;
try{
await api('/api/file/save',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id, path:_previewCurrentPath, content
})});
_previewDirty=false;
// Update read-only views
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
showToast(t('saved'));
}catch(e){setStatus(t('save_failed')+e.message);}
}else{
// Enter edit mode: populate textarea with current content
const currentText = _previewCurrentMode==='code'
? $('previewCode').textContent
: _previewRawContent||'';
$('previewEditArea').value=currentText;
$('previewEditArea').style.display='';
if(_previewCurrentMode==='code') $('previewCode').style.display='none';
else $('previewMd').style.display='none';
// Escape cancels the edit without saving
$('previewEditArea').onkeydown=e=>{
if(e.key==='Escape'){e.preventDefault();cancelEditMode();}
};
}
updateEditBtn();
}
let _previewRawContent = ''; // raw text for md files (to populate editor)
function cancelEditMode(){
// Discard changes and return to read-only view
$('previewEditArea').style.display='none';
$('previewEditArea').onkeydown=null;
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
_previewDirty=false;
updateEditBtn();
}
async function openFile(path){
if(!S.session)return;
const ext=fileExt(path);
// Binary/download-only formats: trigger browser download, don't preview
if(DOWNLOAD_EXTS.has(ext)){
downloadFile(path);
return;
}
$('previewPathText').textContent=path;
$('previewArea').classList.add('visible');
$('fileTree').style.display='none';
const wsSearch=$('wsSearchWrap');if(wsSearch)wsSearch.style.display='none';
_previewCurrentPath = path;
renderFileBreadcrumb(path);
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
showPreview('image');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
} else if(MD_EXTS.has(ext)){
// Markdown: fetch text, render with renderMd, display as formatted HTML
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
showPreview('md');
_previewRawContent = data.content;
$('previewMd').innerHTML=renderMd(data.content);
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
}catch(e){setStatus(t('file_open_failed'));}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
if(data.binary){
// Server flagged this as binary content
downloadFile(path);
return;
}
showPreview('code');
// Apply syntax highlighting based on file extension
const content = data.content || '';
if(['yml','yaml'].includes(ext)){
$('previewCode').className='preview-code hl-yaml';
if(typeof highlightYAML==='function'){
$('previewCode').innerHTML=highlightYAML(content);
}else{
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
}
}else if(ext==='json'){
$('previewCode').className='preview-code hl-json';
$('previewCode').innerHTML=_highlightJSON(content);
}else if(['py','js','ts','sh','bash','zsh','rb','go','rs','java','c','cpp','h','css','scss','html','xml','sql','r','lua','pl','php','swift','kt','dart'].includes(ext)){
$('previewCode').className='preview-code';
$('previewCode').textContent=content;
requestAnimationFrame(()=>{if(typeof highlightCode==='function')highlightCode();});
}else{
// txt, toml, cfg, ini, conf, env, log, etc — readable with line numbers
$('previewCode').className='preview-code hl-text';
$('previewCode').innerHTML=_highlightWithLineNumbers(content);
}
}catch(e){
// If it's a 400/too-large error, offer download instead
downloadFile(path);
}
}
}
function downloadFile(path){
if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const filename=path.split('/').pop();
const a=document.createElement('a');
a.href=url;a.download=filename;
document.body.appendChild(a);a.click();
setTimeout(()=>document.body.removeChild(a),100);
showToast(t('downloading',filename),2000);
}
// ── Render breadcrumb for file preview mode ──────────────────────────────────
function renderFileBreadcrumb(filePath) {
const bar = $('breadcrumbBar');
if (!bar) return;
bar.style.display = 'flex';
const upBtn = $('btnUpDir');
if (upBtn) upBtn.style.display = '';
bar.innerHTML = '';
// Root
const root = document.createElement('span');
root.className = 'breadcrumb-seg breadcrumb-link';
root.textContent = '~';
root.onclick = () => { clearPreview(); loadDir('.'); };
bar.appendChild(root);
const parts = filePath.split('/');
let accumulated = '';
for (let i = 0; i < parts.length; i++) {
const sep = document.createElement('span');
sep.className = 'breadcrumb-sep';
sep.textContent = '/';
bar.appendChild(sep);
accumulated += (accumulated ? '/' : '') + parts[i];
const seg = document.createElement('span');
seg.textContent = parts[i];
if (i < parts.length - 1) {
seg.className = 'breadcrumb-seg breadcrumb-link';
const target = accumulated;
seg.onclick = () => { clearPreview(); loadDir(target); };
} else {
seg.className = 'breadcrumb-seg breadcrumb-current';
}
bar.appendChild(seg);
}
}
// ── Syntax highlighting helpers for file preview ──
function _escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function _highlightWithLineNumbers(text){
return text.split('\n').map(line=>'<span class="code-line">'+_escHtml(line)+'</span>').join('\n');
}
function _highlightJSON(text){
try{
const pretty=JSON.stringify(JSON.parse(text),null,2);
return pretty.split('\n').map(raw=>{
let line=_escHtml(raw);
// Highlight keys
line=line.replace(/^(\s*)(&quot;)([\w\s.\/\-_@:]+)(&quot;)(\s*:)/,'$1<span class="hl-key">$2$3$4</span><span class="hl-value">$5</span>');
// Highlight string values (after colon)
line=line.replace(/(:\s*)(&quot;[^&]*?&quot;)/g,'$1<span class="hl-string">$2</span>');
// Highlight numbers
line=line.replace(/:\s*(\d+\.?\d*)/g,': <span class="hl-number">$1</span>');
// Highlight booleans / null
line=line.replace(/:\s*(true|false|null)/g,': <span class="hl-bool">$1</span>');
return '<span class="code-line">'+line+'</span>';
}).join('\n');
}catch(e){
return _highlightWithLineNumbers(text);
}
}
// ── Workspace file search (server-side recursive) ──
let _wsSearchTimer=null;
function filterWsFiles(){
// Debounce: wait 300ms after last keystroke
clearTimeout(_wsSearchTimer);
_wsSearchTimer=setTimeout(_doWsSearch,300);
}
async function _doWsSearch(){
const input=$('wsSearchInput');
const clearBtn=$('wsSearchClear');
const tree=$('fileTree');
if(!input||!tree)return;
const query=input.value.trim().toLowerCase();
if(clearBtn)clearBtn.classList.toggle('visible',query.length>0);
// Remove any stale "no results" message
const oldNoRes=tree.querySelector('.ws-no-results');
if(oldNoRes)oldNoRes.remove();
// If empty query, restore original file tree
if(!query){
if(typeof renderFileTree==='function')renderFileTree();
return;
}
// Not searchable without a workspace
if(!S.session||!S.session.workspace)return;
// Show loading indicator
tree.innerHTML='<div class="ws-no-results" style="opacity:.5">Suche...</div>';
try{
// Ask server to search recursively
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=.&search=${encodeURIComponent(query)}`);
const results=data.entries||[];
if(!results.length){
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
return;
}
// Render flat result list with path info
tree.innerHTML='';
for(const item of results){
const el=document.createElement('div');
el.className='file-item';
el.style.paddingLeft='10px';
const iconEl=document.createElement('span');
iconEl.className='file-icon';
iconEl.innerHTML=fileIcon(item.name,item.type);
el.appendChild(iconEl);
const nameEl=document.createElement('span');
nameEl.className='file-name';
nameEl.textContent=item.name;
el.appendChild(nameEl);
// Show relative path as hint
if(item.path){
const pathEl=document.createElement('span');
pathEl.className='file-size';
pathEl.style.opacity='.4';
const dir=item.path.substring(0,item.path.lastIndexOf('/'));
pathEl.textContent=dir||'.';
el.appendChild(pathEl);
}
if(item.type==='dir'){
el.onclick=()=>{clearWsSearch();loadDir(item.path);};
}else{
el.onclick=()=>openFile(item.path);
}
tree.appendChild(el);
}
}catch(e){
// Fallback: client-side filter on currently visible items
tree.innerHTML='';
const allItems=S.entries||[];
const matches=allItems.filter(it=>it.name.toLowerCase().includes(query));
if(!matches.length){
tree.innerHTML='<div class="ws-no-results">Keine Dateien gefunden</div>';
}else{
for(const item of matches){
const el=document.createElement('div');
el.className='file-item';el.style.paddingLeft='10px';
el.innerHTML='<span class="file-icon">'+fileIcon(item.name,item.type)+'</span><span class="file-name">'+item.name+'</span>';
el.onclick=item.type==='dir'?()=>{clearWsSearch();loadDir(item.path);}:()=>openFile(item.path);
tree.appendChild(el);
}
}
}
}
// Toggle workspace search visibility
function toggleWsSearch(){
const wrap=$('wsSearchWrap');
if(!wrap)return;
// Show/hide the search bar
wrap.style.display=wrap.style.display==='none'?'flex':'none';
// Focus input when showing
setTimeout(()=>{if(wrap.style.display!=='none'){const inp=$('wsSearchInput');if(inp)inp.focus();}},50);
}
function clearWsSearch(){
const input=$('wsSearchInput');
if(input)input.value='';
const clearBtn=$('wsSearchClear');
if(clearBtn)clearBtn.classList.remove('visible');
if(typeof renderFileTree==='function')renderFileTree();
}