const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null}; const INFLIGHT={}; // keyed by session_id while request in-flight const MSG_QUEUE=[]; // messages queued while a request is in-flight const $=id=>document.getElementById(id); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // Dynamic model labels -- populated by populateModelDropdown(), fallback to static map let _dynamicModelLabels={}; async function populateModelDropdown(){ const sel=$('modelSelect'); if(!sel) return; try{ const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json()); if(!data.groups||!data.groups.length) return; // keep HTML defaults // Clear existing options sel.innerHTML=''; _dynamicModelLabels={}; for(const g of data.groups){ const og=document.createElement('optgroup'); og.label=g.provider; for(const m of g.models){ const opt=document.createElement('option'); opt.value=m.id; opt.textContent=m.label; og.appendChild(opt); _dynamicModelLabels[m.id]=m.label; } sel.appendChild(og); } // Set default model from server if no localStorage preference if(data.default_model && !localStorage.getItem('hermes-webui-model')){ sel.value=data.default_model; // If the default isn't in the list, add it if(sel.value!==data.default_model){ const opt=document.createElement('option'); opt.value=data.default_model; opt.textContent=data.default_model.split('/').pop(); sel.insertBefore(opt,sel.firstChild); sel.value=data.default_model; } } }catch(e){ // API unavailable -- keep the hardcoded HTML options as fallback console.warn('Failed to load models from server:',e.message); } } // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. // Once the user scrolls back to within 80px of the bottom, re-pin. let _scrollPinned=true; (function(){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('scroll',()=>{ const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<80; _scrollPinned=nearBottom; }); })(); function scrollIfPinned(){ if(!_scrollPinned) return; const el=$('messages'); if(el) el.scrollTop=el.scrollHeight; } function scrollToBottom(){ _scrollPinned=true; const el=$('messages'); if(el) el.scrollTop=el.scrollHeight; } function getModelLabel(modelId){ if(!modelId) return 'Unknown'; // Check dynamic labels first, then fall back to splitting the ID if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // Static fallback for common models const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'}; if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; return modelId.split('/').pop()||'Unknown'; } function renderMd(raw){ let s=raw||''; // Mermaid blocks: render as diagram containers (processed after DOM insertion) s=s.replace(/```mermaid\n?([\s\S]*?)```/g,(_,code)=>{ const id='mermaid-'+Math.random().toString(36).slice(2,10); return `
${esc(code.trim())}
`; }); s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`
${esc(lang)}
`:'';return `${h}
${esc(code.replace(/\n$/,''))}
`;}); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/^### (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${esc(t)}

`); s=s.replace(/^---+$/gm,'
'); s=s.replace(/^> (.+)$/gm,(_,t)=>`
${esc(t)}
`); // B8: improved list handling supporting up to 2 levels of indentation s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ const lines=block.trimEnd().split('\n'); let html=''; }); s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{ const lines=block.trimEnd().split('\n'); let html='
    '; for(const l of lines){ const text=l.replace(/^ {0,4}\d+\. /,''); html+=`
  1. ${esc(text)}
  2. `; } return html+'
'; }); s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); // Tables: | col | col | header row followed by | --- | --- | separator then data rows s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ const rows=block.trim().split('\n').filter(r=>r.trim()); if(rows.length<2)return block; const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim()); if(!isSep(rows[1]))return block; const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${esc(c.trim())}`).join(''); const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${esc(c.trim())}`).join(''); const header=`${parseHeader(rows[0])}`; const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
`; }); const parts=s.split(/\n{2,}/); s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `

${p.replace(/\n/g,'
')}

`;}).join('\n'); return s; } function setStatus(t){ const bar=$('activityBar'); const txt=$('activityText'); const dismiss=$('btnDismissStatus'); if(!bar||!txt)return; if(!t){ bar.style.display='none'; txt.textContent=''; if(dismiss)dismiss.style.display='none'; } else { txt.textContent=t; bar.style.display=''; // Show dismiss X only for static/error messages, not transient busy ones const transient = t.endsWith('…') || t === 'Hermes is thinking…'; if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none'; } } function setBusy(v){ S.busy=v; $('btnSend').disabled=v; const dots=$('activityDots'); if(dots) dots.style.display=v?'flex':'none'; if(!v){ setStatus(''); // Always hide Cancel button when not busy const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; updateQueueBadge(); // Drain one queued message after UI settles if(MSG_QUEUE.length>0){ const next=MSG_QUEUE.shift(); updateQueueBadge(); setTimeout(()=>{ $('msg').value=next; send(); }, 120); } } } function updateQueueBadge(){ let badge=$('queueBadge'); if(MSG_QUEUE.length>0){ if(!badge){ badge=document.createElement('div'); badge.id='queueBadge'; badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);'; document.body.appendChild(badge); } badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`; } else { if(badge) badge.remove(); } } function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);} function copyMsg(btn){ const row=btn.closest('.msg-row'); const text=row?row.dataset.rawText:''; if(!text)return; navigator.clipboard.writeText(text).then(()=>{ const orig=btn.innerHTML;btn.innerHTML='✓';btn.style.color='var(--blue)'; setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); }).catch(()=>showToast('Copy failed')); } // ── Reconnect banner (B4/B5: reload resilience) ── const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking function markInflight(sid, streamId) { localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()})); } function clearInflight() { localStorage.removeItem(INFLIGHT_KEY); } function showReconnectBanner(msg) { $('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.'; $('reconnectBanner').classList.add('visible'); } function dismissReconnect() { $('reconnectBanner').classList.remove('visible'); clearInflight(); } async function refreshSession() { dismissReconnect(); if (!S.session) return; try { const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`); S.session = data.session; S.messages = (data.session.messages || []).filter(m => { if (!m || !m.role || m.role === 'tool') return false; if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; } return true; }); syncTopbar(); renderMessages(); showToast('Conversation refreshed'); } catch(e) { setStatus('Refresh failed: ' + e.message); } } async function checkInflightOnBoot(sid) { const raw = localStorage.getItem(INFLIGHT_KEY); if (!raw) return; try { const {sid: inflightSid, streamId, ts} = JSON.parse(raw); if (inflightSid !== sid) { clearInflight(); return; } // Only show banner if the in-flight entry is less than 10 minutes old if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; } // Check if stream is still active const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`); if (status.active) { // Stream is genuinely still running -- show the banner showReconnectBanner('A response is still being generated. Reload when ready?'); } else { // Stream finished. Only show banner if reload happened within 90 seconds // (longer gap = normal completed session, not a mid-stream reload) if (Date.now() - ts < 90 * 1000) { showReconnectBanner('A response was in progress when you last left. Messages may have updated.'); } else { clearInflight(); // completed normally, no banner needed } } } catch(e) { clearInflight(); } } function syncTopbar(){ if(!S.session){ document.title='Hermes'; // Show default workspace name even without a session const sidebarName=$('sidebarWsName'); if(sidebarName && sidebarName.textContent==='Workspace'){ sidebarName.textContent='No workspace'; } return; } const sessionTitle=S.session.title||'Untitled'; $('topbarTitle').textContent=sessionTitle; document.title=sessionTitle+' \u2014 Hermes'; const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); $('topbarMeta').textContent=`${vis.length} messages`; const m=S.session.model||''; $('modelSelect').value=m; // set dropdown first so chip reads consistent value // If session model isn't in the dropdown, add it dynamically if(m && $('modelSelect').value!==m){ const opt=document.createElement('option'); opt.value=m; opt.textContent=getModelLabel(m); $('modelSelect').appendChild(opt); $('modelSelect').value=m; } // Show Clear button only when session has messages const clearBtn=$('btnClearConv'); if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none'; const displayModel=$('modelSelect').value||m; $('modelChip').textContent=getModelLabel(displayModel); const ws=S.session.workspace||''; $('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws; // Update workspace chip in topbar with friendly name from workspace list const wsChipEl=$('wsChip'); if(wsChipEl){ const wsFriendly=getWorkspaceFriendlyName(ws); wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE'; } // Update sidebar workspace display const sidebarName=$('sidebarWsName'); const sidebarPath=$('sidebarWsPath'); if(sidebarName){ sidebarName.textContent=getWorkspaceFriendlyName(ws); } if(sidebarPath){ sidebarPath.textContent=ws; } // modelSelect already set above } function msgContent(m){ // Extract plain text content from a message for filtering let c=m.content||''; if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim(); return String(c).trim(); } function renderMessages(){ const inner=$('msgInner'); const vis=S.messages.filter(m=>{ if(!m||!m.role||m.role==='tool')return false; return msgContent(m)||m.attachments?.length; }); $('emptyState').style.display=vis.length?'none':''; inner.innerHTML=''; // Track original indices (in S.messages) so truncate knows the cut point const visWithIdx=[]; let rawIdx=0; for(const m of S.messages){ if(!m||!m.role||m.role==='tool'){rawIdx++;continue;} if(msgContent(m)||m.attachments?.length) visWithIdx.push({m,rawIdx}); rawIdx++; } for(let vi=0;vip&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); const isUser=m.role==='user'; const isLastAssistant=!isUser&&vi===visWithIdx.length-1; const row=document.createElement('div');row.className='msg-row'; row.dataset.msgIdx=rawIdx; let filesHtml=''; if(m.attachments&&m.attachments.length) filesHtml=`
${m.attachments.map(f=>`
📎 ${esc(f)}
`).join('')}
`; const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(String(content)); // Action buttons for this bubble const editBtn = isUser ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; const tsVal=m._ts||m.timestamp; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; row.innerHTML=`
${isUser?'Y':'H'}
${isUser?'You':'Hermes'}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; row.dataset.rawText = String(content).trim(); inner.appendChild(row); } // Insert settled tool call cards (history view only). // During live streaming, tool cards are rendered in #liveToolCards by the // tool SSE handler and never mixed into the message list until done fires. if(!S.busy && S.toolCalls && S.toolCalls.length){ inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove()); const byAssistant = {}; for(const tc of S.toolCalls){ const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1; if(!byAssistant[key]) byAssistant[key] = []; byAssistant[key].push(tc); } const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]')); for(const [key, cards] of Object.entries(byAssistant)){ const aIdx = parseInt(key); let insertBefore = null; if(aIdx === -1){ for(let i=allRows.length-1;i>=0;i--){ const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10); if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;} } } else { for(const r of allRows){ const ri=parseInt(r.dataset.msgIdx||'-1'); if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;} } } const frag=document.createDocumentFragment(); for(const tc of cards){frag.appendChild(buildToolCard(tc));} // Add expand/collapse toggle for groups with 2+ cards if(cards.length>=2){ const toggle=document.createElement('div'); toggle.className='tool-cards-toggle'; // Collect card elements before they get moved to DOM const cardEls=Array.from(frag.querySelectorAll('.tool-card')); const expandBtn=document.createElement('button'); expandBtn.textContent='Expand all'; expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open')); const collapseBtn=document.createElement('button'); collapseBtn.textContent='Collapse all'; collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open')); toggle.appendChild(expandBtn); toggle.appendChild(collapseBtn); frag.insertBefore(toggle,frag.firstChild); } if(insertBefore) inner.insertBefore(frag,insertBefore); else inner.appendChild(frag); } } scrollToBottom(); // Apply syntax highlighting after DOM is built requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();}); // Refresh todo panel if it's currently open if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){ loadTodos(); } } function toolIcon(name){ const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍', web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧', memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖', send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'}; return icons[name]||'🔧'; } function buildToolCard(tc){ const row=document.createElement('div'); row.className='msg-row tool-card-row'; const icon=toolIcon(tc.name); const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0); let displaySnippet=''; if(tc.snippet){ const s=tc.snippet; if(s.length<=220){displaySnippet=s;} else{ const cutoff=s.slice(0,220); const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; ')); displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff; } } const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length; const runIndicator=tc.done===false?'':''; row.innerHTML=`
${runIndicator} ${icon} ${esc(tc.name)} ${esc(tc.preview||displaySnippet||'')} ${hasDetail?'':''}
${hasDetail?`
${tc.args&&Object.keys(tc.args).length?`
${ Object.entries(tc.args).map(([k,v])=>`
${esc(k)} ${esc(String(v))}
`).join('') }
`:''} ${displaySnippet?`
${esc(displaySnippet)}
${hasMore?``:''}
`:''}
`:''}
`; return row; } // ── Live tool card helpers (called during SSE streaming) ── function appendLiveToolCard(tc){ const container=$('liveToolCards'); if(!container)return; container.style.display=''; // Update existing card if same tool call id (e.g. snippet arrives after done) const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`); if(existing){existing.replaceWith(buildToolCard(tc));return;} const card=buildToolCard(tc); if(tc.tid)card.dataset.tid=tc.tid; container.appendChild(card); } function clearLiveToolCards(){ const container=$('liveToolCards'); if(!container)return; container.innerHTML=''; container.style.display='none'; } // ── Edit + Regenerate ── function editMessage(btn) { if(S.busy) return; const row = btn.closest('.msg-row'); if(!row) return; const msgIdx = parseInt(row.dataset.msgIdx, 10); const originalText = row.dataset.rawText || ''; const body = row.querySelector('.msg-body'); if(!body || row.dataset.editing) return; row.dataset.editing = '1'; // Replace msg-body with an editable textarea const ta = document.createElement('textarea'); ta.className = 'msg-edit-area'; ta.value = originalText; body.replaceWith(ta); // Resize after DOM insertion so scrollHeight is correct requestAnimationFrame(() => { autoResizeTextarea(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }); ta.addEventListener('input', () => autoResizeTextarea(ta)); // Action bar below the textarea const bar = document.createElement('div'); bar.className = 'msg-edit-bar'; bar.innerHTML = ``; ta.after(bar); bar.querySelector('.msg-edit-send').onclick = async () => { const newText = ta.value.trim(); if(!newText) return; await submitEdit(msgIdx, newText); }; bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body); ta.addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); bar.querySelector('.msg-edit-send').click(); } if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); } }); } function cancelEdit(row, originalText, originalBody) { delete row.dataset.editing; const ta = row.querySelector('.msg-edit-area'); const bar = row.querySelector('.msg-edit-bar'); if(ta) ta.replaceWith(originalBody); if(bar) bar.remove(); } function autoResizeTextarea(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 300) + 'px'; } async function submitEdit(msgIdx, newText) { if(!S.session || S.busy) return; // Truncate session at msgIdx (keep messages before the edited one) // then re-send the edited text try { await api('/api/session/truncate', {method:'POST', body:JSON.stringify({ session_id: S.session.session_id, keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward })}); S.messages = S.messages.slice(0, msgIdx); renderMessages(); // Now send the edited message as a new chat $('msg').value = newText; await send(); } catch(e) { setStatus('Edit failed: ' + e.message); } } async function regenerateResponse(btn) { if(!S.session || S.busy) return; // Find the last user message and re-run it // Remove the last assistant message first (truncate to before it) const row = btn.closest('.msg-row'); if(!row) return; const assistantIdx = parseInt(row.dataset.msgIdx, 10); // Find the last user message text (one before this assistant message) let lastUserText = ''; for(let i = assistantIdx - 1; i >= 0; i--) { const m = S.messages[i]; if(m && m.role === 'user') { lastUserText = msgContent(m); break; } } if(!lastUserText) return; try { await api('/api/session/truncate', {method:'POST', body:JSON.stringify({ session_id: S.session.session_id, keep_count: assistantIdx // remove the assistant message })}); S.messages = S.messages.slice(0, assistantIdx); renderMessages(); $('msg').value = lastUserText; await send(); } catch(e) { setStatus('Regenerate failed: ' + e.message); } } function highlightCode(container) { // Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area) if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return; const el = container || $('msgInner'); if(!el) return; Prism.highlightAllUnder(el); } function addCopyButtons(container){ const el=container||$('msgInner'); if(!el) return; el.querySelectorAll('pre > code').forEach(codeEl=>{ const pre=codeEl.parentElement; if(pre.querySelector('.code-copy-btn')) return; const btn=document.createElement('button'); btn.className='code-copy-btn'; btn.textContent='Copy'; btn.onclick=(e)=>{ e.stopPropagation(); navigator.clipboard.writeText(codeEl.textContent).then(()=>{ btn.textContent='Copied!'; setTimeout(()=>{btn.textContent='Copy';},1500); }); }; const header=pre.previousElementSibling; if(header&&header.classList.contains('pre-header')){ header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.appendChild(btn); }else{ pre.style.position='relative'; btn.style.cssText='position:absolute;top:6px;right:6px;'; pre.appendChild(btn); } }); } let _mermaidLoading=false; let _mermaidReady=false; function renderMermaidBlocks(){ const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])'); if(!blocks.length) return; if(!_mermaidReady){ if(!_mermaidLoading){ _mermaidLoading=true; const script=document.createElement('script'); script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js'; script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT'; script.crossOrigin='anonymous'; script.onload=()=>{ if(typeof mermaid!=='undefined'){ mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{ primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096', secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568', }}); _mermaidReady=true; renderMermaidBlocks(); } }; document.head.appendChild(script); } return; } blocks.forEach(async(block)=>{ block.dataset.rendered='true'; const code=block.textContent; const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2)); try{ const {svg}=await mermaid.render(id,code); block.innerHTML=svg; block.classList.add('mermaid-rendered'); }catch(e){ // Fall back to showing as a code block block.innerHTML=`
mermaid
${esc(code)}
`; } }); } function appendThinking(){ $('emptyState').style.display='none'; const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow'; row.innerHTML=`
H
Hermes
`; $('msgInner').appendChild(row);scrollToBottom(); } function removeThinking(){const el=$('thinkingRow');if(el)el.remove();} function fileIcon(name, type){ if(type==='dir') return '📁'; const e=fileExt(name); if(IMAGE_EXTS.has(e)) return '📷'; if(MD_EXTS.has(e)) return '📝'; if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️'; if(e==='.py') return '🐍'; if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡'; if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙'; if(e==='.sh'||e==='.bash') return '💻'; if(e==='.pdf') return '⬇️'; return '📄'; } function renderFileTree(){ const box=$('fileTree');box.innerHTML=''; for(const item of S.entries){ const el=document.createElement('div');el.className='file-item'; // Icon const iconEl=document.createElement('span'); iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type); el.appendChild(iconEl); // Name -- takes all remaining space, truncates with ellipsis const nameEl=document.createElement('span'); nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename'; // Inline rename on double-click nameEl.ondblclick=(e)=>{ e.stopPropagation(); const inp=document.createElement('input'); inp.className='file-rename-input';inp.value=item.name; inp.onclick=(e2)=>e2.stopPropagation(); const finish=async(save)=>{ inp.onblur=null; // prevent double-call: Enter triggers blur after replaceWith if(save){ const newName=inp.value.trim(); if(newName&&newName!==item.name){ try{ await api('/api/file/rename',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id,path:item.path,new_name:newName })}); showToast(`Renamed to ${newName}`); await loadDir('.'); }catch(err){showToast('Rename failed: '+err.message);} } } inp.replaceWith(nameEl); }; inp.onkeydown=(e2)=>{ if(e2.key==='Enter'){e2.preventDefault();finish(true);} if(e2.key==='Escape'){e2.preventDefault();finish(false);} }; inp.onblur=()=>finish(false); nameEl.replaceWith(inp); setTimeout(()=>{inp.focus();inp.select();},10); }; el.appendChild(nameEl); // Size -- only for files, right-aligned, shrinks but never wraps if(item.type==='file'&&item.size){ const sizeEl=document.createElement('span'); sizeEl.className='file-size'; sizeEl.textContent=`${(item.size/1024).toFixed(1)}k`; el.appendChild(sizeEl); } // Delete button -- for files, shown on hover if(item.type==='file'){ const del=document.createElement('button'); del.className='file-del-btn';del.title='Delete';del.textContent='×'; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; el.appendChild(del); } el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path); box.appendChild(el); } } async function deleteWorkspaceFile(relPath, name){ if(!S.session)return; if(!confirm(`Delete ${name}?`))return; try{ await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); showToast(`Deleted ${name}`); // Close preview if we just deleted the viewed file if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick(); await loadDir('.'); }catch(e){setStatus('Delete failed: '+e.message);} } async function promptNewFile(){ if(!S.session)return; const name=prompt('New file name (e.g. notes.md):',''); if(!name||!name.trim())return; try{ await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})}); showToast(`Created ${name.trim()}`); await loadDir('.'); // Open the new file immediately openFile(name.trim()); }catch(e){setStatus('Create failed: '+e.message);} } async function promptNewFolder(){ if(!S.session)return; const name=prompt('New folder name:',''); if(!name||!name.trim())return; try{ await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim()})}); showToast(`Created folder ${name.trim()}`); await loadDir('.'); }catch(e){setStatus('Create folder failed: '+e.message);} } function renderTray(){ const tray=$('attachTray');tray.innerHTML=''; if(!S.pendingFiles.length){tray.classList.remove('has-files');return;} tray.classList.add('has-files'); S.pendingFiles.forEach((f,i)=>{ const chip=document.createElement('div');chip.className='attach-chip'; chip.innerHTML=`📎 ${esc(f.name)} `; chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();}; tray.appendChild(chip); }); } function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();} async function uploadPendingFiles(){ if(!S.pendingFiles.length||!S.session)return[]; const names=[];let failures=0; const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); barWrap.classList.add('active');bar.style.width='0%'; const total=S.pendingFiles.length; for(let i=0;i0)throw new Error(`All ${total} upload(s) failed`); return names; }