/// // Virtual scroller stub — removed, using simple DOM rendering const S: State = {session: null, messages: [], entries: [], busy: false, pendingFiles: [], toolCalls: [], activeStreamId: null, currentDir: '.', activeProfile: 'default'} as State; const INFLIGHT={}; // keyed by session_id while request in-flight const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns // eslint-disable-next-line @typescript-eslint/no-inferrable-types const $el = (id: string) => document.getElementById(id) as HTMLElement | null; const $ = $el as (id: string) => HTMLElement | null; function _getSessionQueue(sid, create=false){ if(!sid) return []; if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[]; return SESSION_QUEUES[sid]||[]; } function queueSessionMessage(sid, payload){ if(!sid||!payload) return 0; const q=_getSessionQueue(sid,true); q.push(payload); return q.length; } function shiftQueuedSessionMessage(sid){ const q=_getSessionQueue(sid,false); if(!q.length) return null; const next=q.shift(); if(!q.length) delete SESSION_QUEUES[sid]; return next; } function getQueuedSessionCount(sid){ return _getSessionQueue(sid,false).length; } const _esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const esc = _esc as (s: unknown) => string; // Dynamic model labels -- populated by populateModelDropdown(), fallback to static map let _dynamicModelLabels={}; // ── Smart model resolver ──────────────────────────────────────────────────── // Finds the best matching option value in a `; const _ci=_custRow.querySelector('.model-custom-input'); const _cb=_custRow.querySelector('.model-custom-btn'); const _applyCustom=()=>{const v=(_ci as unknown as HTMLInputElement).value.trim();if(!v)return;selectModelFromDropdown(v);(_ci as unknown as HTMLInputElement).value='';}; _cb.onclick=_applyCustom; _ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}}); _ci.addEventListener('click',e=>e.stopPropagation()); dd.appendChild(_custRow); } async function selectModelFromDropdown(value: string){ const sel=$('modelSelect') as unknown as HTMLSelectElement | null; if(!sel||sel.value===value) { closeModelDropdown(); return; } // If the value isn't in the option list (custom model ID), add a temporary option // so sel.value assignment succeeds and the model chip shows the custom ID. if(!Array.from(sel.options).some(o=>o.value===value)){ const opt=document.createElement('option'); opt.value=value; opt.textContent=value.split('/').pop()||value; opt.dataset.custom='1'; // Remove any previous custom option before adding new one sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove()); sel.appendChild(opt); } sel.value=value; syncModelChip(); closeModelDropdown(); if(typeof sel.onchange==='function') sel.onchange(new Event('change')); } function toggleModelDropdown(){ const dd=$('composerModelDropdown'); const chip=$('composerModelChip'); const sel=$('modelSelect'); if(!dd||!chip||!sel) return; const open=dd.classList.contains('open'); if(open){closeModelDropdown(); return;} if(typeof closeProfileDropdown==='function') closeProfileDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown(); renderModelDropdown(); dd.classList.add('open'); _positionModelDropdown(); chip.classList.add('active'); } function closeModelDropdown(){ const dd=$('composerModelDropdown'); const chip=$('composerModelChip'); if(dd) dd.classList.remove('open'); if(chip) chip.classList.remove('active'); } document.addEventListener('click',e=>{ const target=e.target as Element; if(!target.closest('#composerModelChip') && !target.closest('#composerModelDropdown')) closeModelDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('composerModelDropdown'); if(dd&&dd.classList.contains('open')) _positionModelDropdown(); }); // ── Agent selector dropdown ───────────────────────────────────────────────── const AGENT_META = { rose: {emoji:'🌹', name:'Rose', domain:'Orchestrator & Main Interface'}, lotus: {emoji:'🪷', name:'Lotus', domain:'Health, Fitness & Recovery'}, 'forget-me-not':{emoji:'🌼', name:'Forget-me-not', domain:'Calendar, Time & Social'}, sunflower: {emoji:'🌻', name:'Sunflower', domain:'Finance, Wealth & Subscriptions'}, iris: {emoji:'⚜️', name:'Iris', domain:'Career, Learning & Focus'}, ivy: {emoji:'🌿', name:'Ivy', domain:'Smart Home & Environment'}, dandelion: {emoji:'🛡️', name:'Dandelion', domain:'Communication Triage'}, root: {emoji:'🌳', name:'Root', domain:'DevOps, Logs & System Health'}, }; function renderAgentDropdown(){ const dd=$('composerAgentDropdown'); const sel=$('agentSelect'); if(!dd||!sel) return; const current=sel.value; const groups={'Tier-1':['rose'],'Tier-2':['lotus','forget-me-not','sunflower','iris','ivy','dandelion','root']}; let html=''; for(const [grp,ids] of Object.entries(groups)){ html+=`
${grp}
`; for(const id of ids){ const m=AGENT_META[id]; const active=id===current?' active':''; html+=`
${m.emoji} ${m.name} ${m.domain}
`; } } dd.innerHTML=html; } function toggleAgentDropdown(){ const dd=$('composerAgentDropdown'); const chip=$('composerAgentChip'); if(!dd||!chip) return; if(dd.classList.contains('open')){ dd.classList.remove('open'); chip.classList.remove('active'); return; } closeModelDropdown(); if(typeof closeProfileDropdown==='function') closeProfileDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown(); renderAgentDropdown(); dd.classList.add('open'); chip.classList.add('active'); // position below chip const chipRect=chip.getBoundingClientRect(); const wrap=chip.closest('.composer-agent-wrap'); const wrapRect=wrap.getBoundingClientRect(); dd.style.left=(chipRect.left-wrapRect.left)+'px'; } function closeAgentDropdown(){ const dd=$('composerAgentDropdown'); const chip=$('composerAgentChip'); if(dd) dd.classList.remove('open'); if(chip) chip.classList.remove('active'); } function selectAgentFromDropdown(value){ const sel=$('agentSelect'); if(!sel) return; const prevAgent=sel.value; sel.value=value; syncAgentChip(); closeAgentDropdown(); // Save to session / localStorage if(typeof S!=='undefined') S.session={...S.session,agent:value}; try{localStorage.setItem('hermes-webui-agent',value);}catch(e){} try{localStorage.setItem('hermes.chat_agent',value);}catch(e){} // If agent actually changed and we have a session, add a system message if(prevAgent!==value && typeof S!=='undefined'&&S.session&&S.messages){ const prevMeta=AGENT_META[prevAgent]||{emoji:'🌹',name:prevAgent||'Rose'}; const newMeta=AGENT_META[value]||{emoji:'🌹',name:value||'Rose'}; const sysMsg={ role:'system', content:`← Switched to ${newMeta.emoji} **${newMeta.name}**`, _ts:Math.floor(Date.now()/1000) }; S.messages.push(sysMsg); if(typeof renderMessages==='function') renderMessages(); } } function syncAgentChip(){ const sel=$('agentSelect'); const icon=$('composerAgentIcon'); const label=$('composerAgentLabel'); if(!sel||!icon||!label) return; const m=AGENT_META[sel.value]||AGENT_META.rose; icon.textContent=m.emoji; label.textContent=m.name; // Sync topbar selector too const topIcon=$('agentSelectorIcon'); const topLabel=$('agentSelectorLabel'); if(topIcon) topIcon.textContent=m.emoji; if(topLabel) topLabel.textContent=m.name; // Also sync localStorage localStorage.setItem('hermes-webui-agent', sel.value); localStorage.setItem('hermes.chat_agent', sel.value); } // Init agent chip from localStorage on load window.addEventListener('DOMContentLoaded',()=>{ try{ const saved=localStorage.getItem('hermes-webui-agent'); if(saved){ const sel=$('agentSelect'); if(sel&&Array.from((sel as unknown as HTMLSelectElement).options).some(o=>o.value===saved)){ sel.value=saved; } } }catch(e){} syncAgentChip(); // Also init topbar from localStorage try{ const savedAgent=localStorage.getItem('hermes.chat_agent'); if(savedAgent){ const topIcon=$('agentSelectorIcon'); const topLabel=$('agentSelectorLabel'); if(topIcon&&topLabel&&AGENT_META[savedAgent]){ topIcon.textContent=AGENT_META[savedAgent].emoji; topLabel.textContent=AGENT_META[savedAgent].name; } } }catch(e){} }); document.addEventListener('click',e=>{ const target = e.target as Element; if(!target.closest('#composerAgentChip') && !target.closest('#composerAgentDropdown')) closeAgentDropdown(); }); // ── 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; // Show/hide scroll-to-bottom FAB const fab=document.getElementById('scrollBottomFab'); if(fab) fab.classList.toggle('visible',!nearBottom); }); })(); function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} // Context usage indicator in composer footer function _syncCtxIndicator(usage){ const wrap=$('ctxIndicatorWrap'); const el=$('ctxIndicator'); if(!el)return; const promptTok=usage.last_prompt_tokens||usage.input_tokens||0; // Use session-cumulative tokens for display (labeled as such) const sessIn=usage._session_input_tokens||0; const sessOut=usage._session_output_tokens||0; const sessTotal=sessIn+sessOut; const ctxWindow=usage.context_length||0; const cost=usage.estimated_cost; // Show indicator whenever we have any usage data (tokens or cost) if(!promptTok&&!sessTotal&&!cost){ if(wrap) wrap.style.display='none'; return; } if(wrap) wrap.style.display=''; const hasCtxWindow=!!(promptTok&&ctxWindow); const pct=hasCtxWindow?Math.min(100,Math.round((promptTok/ctxWindow)*100)):0; const ring=$('ctxRingValue'); const center=$('ctxPercent'); const usageLine=$('ctxTooltipUsage'); const tokensLine=$('ctxTooltipTokens'); const thresholdLine=$('ctxTooltipThreshold'); const costLine=$('ctxTooltipCost'); if(ring){ const circumference=61.261056745; ring.style.strokeDasharray=String(circumference); ring.style.strokeDashoffset=String(circumference*(1-pct/100)); } if(center) center.textContent=hasCtxWindow?String(pct):'\u00b7'; el.classList.toggle('ctx-mid',pct>50&&pct<=75); el.classList.toggle('ctx-high',pct>75); // Label session-cumulative tokens clearly const sessLabel=sessTotal>0?`Session: ${_fmtTokens(sessIn)} in \u00b7 ${_fmtTokens(sessOut)} out`:`${_fmtTokens(sessTotal)} tokens`; let label=hasCtxWindow?`Context window ${pct}% used`:`${sessLabel}`; if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; el.setAttribute('aria-label',label); if(usageLine) usageLine.textContent=hasCtxWindow?`${pct}% used (${Math.max(0,100-pct)}% left)`:sessLabel; if(tokensLine) tokensLine.textContent=hasCtxWindow?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:sessLabel; const threshold=usage.threshold_tokens||0; if(thresholdLine){ if(threshold&&ctxWindow){ thresholdLine.style.display=''; thresholdLine.textContent=`Auto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`; }else{ thresholdLine.style.display='none'; thresholdLine.textContent=''; } } if(costLine){ if(cost){ costLine.style.display=''; costLine.textContent=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; }else{ costLine.style.display='none'; costLine.textContent=''; } } } // Sync session-total (cumulative Σ in · Σ out) into the composer footer as a visible line. function _syncSessionTotalInFooter(session){ if(!session) return; const id='sessionTotalFooter'; const wrap=document.querySelector('.composer-footer'); if(!wrap) return; let el=document.getElementById(id); if(!el){ el=Object.assign(document.createElement('span'),{id,style:'font-size:11px;opacity:.55;margin-left:auto;white-space:nowrap'}); wrap.appendChild(el); } const inT=session.input_tokens||0; const outT=session.output_tokens||0; el.textContent=`\u03a3 ${_fmtTokens(inT)} in \u00b7 ${_fmtTokens(outT)} out`; } 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||''; // ── MEDIA: token stash (must run first, before any other processing) ─────── // Detect MEDIA: tokens emitted by the agent (e.g. screenshots, // generated images) and replace them with inline or download links. // Stashed so the path/URL is never processed as markdown. const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico)$/i; const media_stash=[]; s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{ media_stash.push(raw_ref); return '\x00D'+(media_stash.length-1)+'\x00'; }); // ── End MEDIA stash ───────────────────────────────────────────────────────── // Pre-pass: decode HTML entities first so markdown processing works correctly. // This prevents double-escaping when LLM outputs entities like < > & const decode=s=>s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'"); s=decode(s); // Pre-pass: convert safe inline HTML tags the model may emit into their // markdown equivalents so the pipeline can render them correctly. // Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore). // Unsafe tags (anything not in the allowlist) are left as-is and will be // HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk. // Fence stash: protect code blocks and backtick spans from all further processing // Must run BEFORE math_stash so $..$ inside code spans is not extracted as math const fence_stash=[]; s=s.replace(/(```[\s\S]*?```|`[^`\n]+`)/g,m=>{fence_stash.push(m);return '\x00F'+(fence_stash.length-1)+'\x00';}); // Math stash: protect $$..$$ and $..$ from markdown processing // Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents const math_stash=[]; // Display math: $$...$$ (must come before inline to avoid mis-parsing) s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); // Inline math: $...$ — require non-space at boundaries to avoid false positives // e.g. "costs $5 and $10" should not trigger (space after opening $) s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); // Also stash \(...\) and \[...\] LaTeX delimiters s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';}); // Safe tag → markdown equivalent (these produce the same output as **text** etc.) s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**'); s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**'); s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*'); s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*'); s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); s=s.replace(//gi,'\n'); // Restore stashed code blocks s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]); // 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 normalizedLang=(lang||'').trim().toLowerCase(); const h=normalizedLang?`
${esc(normalizedLang)}
`:''; const langAttr=normalizedLang?` class="language-${esc(normalizedLang)}"`:''; return `${h}
${esc(code.replace(/\n$/,''))}
`; }); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); // inlineMd: process bold/italic/code/links within a single line of text. // Used inside list items and blockquotes where the text may already contain // HTML from the pre-pass → bold pipeline, so we cannot call esc() directly. function inlineMd(t){ // Stash backtick code spans first so bold/italic never esc() their content const _code_stash=[]; t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;}); t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); // #487: Image pass — runs while code stash is active so ![x](url) inside // backticks stays protected as a \x00C token and is never rendered as . // Must run before _code_stash restore and before _link_stash so the image // is not consumed by the [label](url) link regex. t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`${esc(alt)}`); // Stash rendered tags so autolink never matches URLs inside src= const _img_stash=[]; t=t.replace(/(]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;}); t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); // Escape any plain text that isn't already wrapped in a tag we produced // by escaping bare < > that are not part of our own tags const SAFE_INLINE=/^<\/?(strong|em|code|a|img)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } // Stash tags from the backtick pass above so the outer bold/italic // regexes don't esc() their content (e.g. **`code`** → code) const _ob_stash=[]; s=s.replace(/([^<]*<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;}); 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(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]); s=s.replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

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

${inlineMd(t)}

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

${inlineMd(t)}

`); s=s.replace(/^---+$/gm,'
'); s=s.replace(/^> (.+)$/gm,(_,t)=>`
${inlineMd(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='
    '; for(const l of lines){ const indent=/^ {2,}/.test(l); const text=l.replace(/^ {0,4}[-*+] /,''); if(indent) html+=`
  • ${inlineMd(text)}
  • `; else html+=`
  • ${inlineMd(text)}
  • `; } return 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. ${inlineMd(text)}
  2. `; } return html+'
'; }); // Tables: | col | col | header row followed by | --- | --- | separator then data rows // NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells // is handled by inlineMd() only — prevents double-linking. 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=>`${inlineMd(c.trim())}`).join(''); const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(c.trim())}`).join(''); const header=`${parseHeader(rows[0])}`; const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
`; }); // #487: Outer image pass — handles ![alt](url) in plain paragraphs (outside tables/lists). // Runs AFTER the table pass (images in table cells are handled by inlineMd() above). // Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link. s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`${esc(alt)}`); // Outer link pass for labeled links in plain paragraphs (outside table cells). // Runs AFTER the table pass so table cells are processed by inlineMd() only. // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Escape any remaining HTML tags that are NOT from our own markdown output. // Our pipeline only emits: ,,,
,,
    ,
      ,
    1. , // ,,,,
      ,,
      ,
      ,

      ,
      ,, //

      (mermaid/pre-header). Everything else is untrusted input. const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|img|div|span)([\s>]|$)/i; s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag)); // Autolink: convert plain URLs to clickable links. // Stash existing tags first so we never re-link a URL already inside href="...". const _al_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>|]*>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;}); s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{ // Strip trailing punctuation that was likely not part of the URL const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):''; const clean=trail?url.slice(0,-1):url; return `${esc(clean)}${trail}`; }); s=s.replace(/\x00B(\d+)\x00/g,(_,i)=>_al_stash[+i]); // Restore math stash → katex placeholder spans/divs // These will be rendered by renderKatexBlocks() after DOM insertion s=s.replace(/\x00M(\d+)\x00/g,(_,i)=>{ const item=math_stash[+i]; if(item.type==='display'){ return `
      ${esc(item.src)}
      `; } return `${esc(item.src)}`; }); 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'); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ const ref=media_stash[+i]; // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ if(_IMAGE_EXTS.test(ref.split('?')[0])){ return `image`; } return `${esc(ref)}`; } // Local file path const apiUrl='/api/media?path='+encodeURIComponent(ref); if(_IMAGE_EXTS.test(ref)){ return `${esc(ref.split('/').pop())}`; } // Non-image local file — show download link with filename const fname=esc(ref.split('/').pop()||ref); return `📎 ${fname}`; }); // ── End MEDIA restore ────────────────────────────────────────────────────── return s; } function setStatus(t){ if(!t)return; showToast(t, 4000); } function setComposerStatus(t){ const el=$('composerStatus'); if(!el)return; if(!t){ el.style.display='none'; el.textContent=''; return; } el.textContent=t; el.style.display=''; } let _composerLockState=null; function lockComposerForClarify(placeholderText){ const input=$('msg'); if(!input) return; if(!_composerLockState){ _composerLockState={ disabled: input.disabled, placeholder: input.placeholder, }; } input.disabled=true; if(placeholderText) input.placeholder=placeholderText; updateSendBtn(); } function unlockComposerForClarify(){ const input=$('msg'); if(!input) return; if(_composerLockState){ input.disabled=!!_composerLockState.disabled; if(typeof _composerLockState.placeholder==='string'){ input.placeholder=_composerLockState.placeholder; } _composerLockState=null; }else{ input.disabled=false; } updateSendBtn(); } function updateSendBtn(){ const btn=$('btnSend'); if(!btn) return; const msg=$('msg'); const hasContent=msg&&msg.value.trim().length>0||S.pendingFiles.length>0; const canSend=hasContent&&!S.busy&&!(msg&&msg.disabled); // Hide while busy (cancel button takes its place); show otherwise btn.style.display=S.busy?'none':''; btn.disabled=!canSend; if(canSend&&!btn.classList.contains('visible')){ btn.classList.remove('visible'); requestAnimationFrame(()=>btn.classList.add('visible')); } } function setBusy(v){ S.busy=v; updateSendBtn(); if(!v){ setStatus(''); setComposerStatus(''); // Always hide Cancel button when not busy const _cb=$('btnCancel');if(_cb)_cb.style.display='none'; const sid=S.session&&S.session.session_id; updateQueueBadge(sid); // Drain one queued message for the currently viewed session after UI settles const next=sid?shiftQueuedSessionMessage(sid):null; if(next){ updateQueueBadge(sid); setTimeout(()=>{ $('msg').value=next.text||''; S.pendingFiles=Array.isArray(next.files)?[...next.files]:[]; autoResize(); renderTray(); send(); },120); } } } function updateQueueBadge(sessionId){ const sid=sessionId||(S.session&&S.session.session_id); const count=sid?getQueuedSessionCount(sid):0; let badge=$('queueBadge'); if(count>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=count===1?'1 message queued':`${count} 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);} // ── Shared app dialogs ─────────────────────────────────────────────────────── // showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls // throughout the UI. Both return Promises and support: title, message, confirmLabel, // cancelLabel, danger (confirm only), placeholder/value/inputType (prompt only). const APP_DIALOG={resolve:null,kind:null,lastFocus:null}; let _appDialogBound=false; function _isAppDialogOpen(){ const overlay=$('appDialogOverlay'); return !!(overlay&&overlay.style.display!=='none'); } function _getAppDialogFocusable(){ return [$('appDialogInput'), $('appDialogCancel'), $('appDialogConfirm'), $('appDialogClose')] .filter(el=>el&&el.style.display!=='none'&&!el.disabled); } function _finishAppDialog(result, restoreFocus=true){ const overlay=$('appDialogOverlay'); const dialog=$('appDialog'); const input=$('appDialogInput'); const confirmBtn=$('appDialogConfirm'); const resolve=APP_DIALOG.resolve; const lastFocus=APP_DIALOG.lastFocus; APP_DIALOG.resolve=null; APP_DIALOG.kind=null; APP_DIALOG.lastFocus=null; if(overlay){overlay.style.display='none';overlay.setAttribute('aria-hidden','true');} if(dialog) dialog.setAttribute('role','dialog'); if(input){input.value='';input.style.display='none';input.placeholder='';} if(confirmBtn){confirmBtn.classList.remove('danger');confirmBtn.textContent=t('dialog_confirm_btn');} if(restoreFocus&&lastFocus&&typeof lastFocus.focus==='function'){setTimeout(()=>lastFocus.focus(),0);} if(resolve) resolve(result); } function _ensureAppDialogBindings(){ if(_appDialogBound) return; _appDialogBound=true; const overlay=$('appDialogOverlay'); const cancelBtn=$('appDialogCancel'); const confirmBtn=$('appDialogConfirm'); const closeBtn=$('appDialogClose'); if(overlay){ overlay.addEventListener('click',e=>{ if(e.target===overlay) _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); }); } if(cancelBtn) cancelBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false)); if(closeBtn) closeBtn.addEventListener('click',()=>_finishAppDialog(APP_DIALOG.kind==='prompt'?null:false)); if(confirmBtn){ confirmBtn.addEventListener('click',()=>{ if(APP_DIALOG.kind==='prompt'){ const input=$('appDialogInput'); _finishAppDialog(input?input.value:null); }else{ _finishAppDialog(true); } }); } document.addEventListener('keydown',e=>{ if(!_isAppDialogOpen()) return; if(e.key==='Escape'){ e.preventDefault(); _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); return; } if(e.key==='Enter'){ const target=e.target as Element; const isTextarea=target&&target.tagName==='TEXTAREA'; if(!isTextarea){ e.preventDefault(); const targetEl=e.target as HTMLElement; if(targetEl===cancelBtn||targetEl===closeBtn){ _finishAppDialog(APP_DIALOG.kind==='prompt'?null:false); }else if(APP_DIALOG.kind==='prompt'){ const input=$('appDialogInput'); _finishAppDialog(input?input.value:null); }else{ _finishAppDialog(true); } } return; } if(e.key==='Tab'){ const nodes=_getAppDialogFocusable(); if(!nodes.length) return; const targetEl=document.activeElement as unknown as HTMLElement; const idx=nodes.indexOf(targetEl); let nextIdx=idx; if(e.shiftKey){nextIdx=idx<=0?nodes.length-1:idx-1;} else{nextIdx=idx===-1||idx===nodes.length-1?0:idx+1;} e.preventDefault(); nodes[nextIdx].focus(); } }, true); } function showConfirmDialog(opts?: DialogOpts){ _ensureAppDialogBindings(); if(APP_DIALOG.resolve) _finishAppDialog(false,false); const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'), desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm'); APP_DIALOG.resolve=null;APP_DIALOG.kind='confirm';APP_DIALOG.lastFocus=document.activeElement; const o = opts || {}; if(title) title.textContent=o.title||t('dialog_confirm_title'); if(desc) desc.textContent=o.message||''; if(input){input.style.display='none';input.value='';} if(cancelBtn) cancelBtn.textContent=o.cancelLabel||t('cancel'); if(confirmBtn){ confirmBtn.textContent=o.confirmLabel||t('dialog_confirm_btn'); confirmBtn.classList.toggle('danger',!!o.danger); } if(dialog) dialog.setAttribute('role',o.danger?'alertdialog':'dialog'); if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');} return new Promise(resolve=>{ APP_DIALOG.resolve=resolve; setTimeout(()=>((o.focusCancel?cancelBtn:confirmBtn)||confirmBtn||cancelBtn).focus(),0); }); } function showPromptDialog(opts?: DialogOpts){ _ensureAppDialogBindings(); if(APP_DIALOG.resolve) _finishAppDialog(null,false); const overlay=$('appDialogOverlay'),dialog=$('appDialog'),title=$('appDialogTitle'), desc=$('appDialogDesc'),input=$('appDialogInput'),cancelBtn=$('appDialogCancel'),confirmBtn=$('appDialogConfirm'); APP_DIALOG.resolve=null;APP_DIALOG.kind='prompt';APP_DIALOG.lastFocus=document.activeElement; const o = opts || {}; if(title) title.textContent=o.title||t('dialog_prompt_title'); if(desc) desc.textContent=o.message||''; if(input){ (input as HTMLInputElement).type=o.inputType||'text';input.style.display=''; input.value=o.value||'';input.placeholder=o.placeholder||''; (input as HTMLInputElement).autocomplete='off';(input as HTMLInputElement).spellcheck=false; } if(cancelBtn) cancelBtn.textContent=o.cancelLabel||t('cancel'); if(confirmBtn){confirmBtn.textContent=o.confirmLabel||t('create');confirmBtn.classList.remove('danger');} if(dialog) dialog.setAttribute('role','dialog'); if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');} return new Promise(resolve=>{ APP_DIALOG.resolve=resolve; setTimeout(()=>{if(input&&input.style.display!=='none')input.focus();else if(confirmBtn)confirmBtn.focus();},0); }); } 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=li('check',13);btn.style.color='var(--blue)'; setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); }).catch(()=>showToast('Copy failed')); } // ── Reconnect banner (B4/B5: reload resilience) ── // Per-tab ID prevents multi-tab localStorage conflicts — each browser tab gets its own suffix const _TAB_ID = (() => { try { const existing = sessionStorage.getItem('hermes-tab-id'); if (existing) return existing; const id = Math.random().toString(36).slice(2, 9); sessionStorage.setItem('hermes-tab-id', id); return id; } catch (_) { return 'default'; } })(); const INFLIGHT_KEY = `hermes-webui-inflight:${_TAB_ID}`; const INFLIGHT_STATE_KEY = `hermes-webui-inflight-state:${_TAB_ID}`; // Cleanup when this tab closes if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { try { localStorage.removeItem(INFLIGHT_KEY); localStorage.removeItem(INFLIGHT_STATE_KEY); } catch (_) {} }); } function _readInflightStateMap(){ try{ const raw=localStorage.getItem(INFLIGHT_STATE_KEY); const parsed=raw?JSON.parse(raw):{}; return parsed&&typeof parsed==='object'?parsed:{}; }catch(_){ return {}; } } function saveInflightState(sid, state){ if(!sid||!state) return; try{ const all=_readInflightStateMap(); all[sid]={...state,updated_at:Date.now(),tabId:_TAB_ID}; localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); }catch(_){ } } function loadInflightState(sid, streamId){ if(!sid) return null; const all=_readInflightStateMap(); const entry=all[sid]; if(!entry) return null; if(streamId&&entry.streamId&&entry.streamId!==streamId) return null; if(entry.updated_at&&Date.now()-entry.updated_at>10*60*1000){ clearInflightState(sid); return null; } return entry; } function clearInflightState(sid){ if(!sid) return; try{ const all=_readInflightStateMap(); if(!(sid in all)) return; delete all[sid]; if(Object.keys(all).length) localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); else localStorage.removeItem(INFLIGHT_STATE_KEY); }catch(_){ } } function markInflight(sid, streamId) { localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now(), tabId:_TAB_ID})); } 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 || []; const pendingMsg=getPendingSessionMessage(data.session); if(pendingMsg) S.messages.push(pendingMsg); S.activeStreamId=data.session.active_stream_id||null; syncTopbar(); renderMessages(); showToast('Conversation refreshed'); } catch(e) { setStatus('Refresh failed: ' + e.message); } } // ── Update banner ── function _showUpdateBanner(data){ const parts=[]; if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`); if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`); if(!parts.length)return; const msg=$('updateMsg'); if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available'; const banner=$('updateBanner'); // Respect dismissal for 24 hours const dismissed=localStorage.getItem('hermes-update-dismissed'); const show=!dismissed||(Date.now()-parseInt(dismissed))>604800000; if(banner && show) banner.classList.add('visible'); window._updateData=data; } function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); localStorage.setItem('hermes-update-dismissed',Date.now().toString()); } async function applyUpdates(){ const btn=$('btnApplyUpdate'); if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} const targets=[]; if(window._updateData?.webui?.behind>0) targets.push('webui'); if(window._updateData?.agent?.behind>0) targets.push('agent'); try{ for(const target of targets){ const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})}); if(!res.ok){ showToast('Update failed ('+target+'): '+(res.message||'unknown error')); if(btn){btn.disabled=false;btn.textContent='Update Now';} return; } } showToast('Updated! Reloading\u2026'); sessionStorage.removeItem('hermes-update-checked'); sessionStorage.removeItem('hermes-update-dismissed'); setTimeout(()=>location.reload(),1500); }catch(e){ showToast('Update failed: '+e.message); if(btn){btn.disabled=false;btn.textContent='Update Now';} } } function getPendingSessionMessage(session){ const text=String(session?.pending_user_message||'').trim(); if(!text) return null; const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[]; const messages=Array.isArray(session?.messages)?session.messages:[]; const lastUser=[...messages].reverse().find(m=>m&&m.role==='user'); if(lastUser){ const lastText=String(msgContent(lastUser)||'').trim(); if(lastText===text){ if(attachments.length&&!lastUser.attachments?.length) lastUser.attachments=attachments; return null; } } return { role:'user', content:text, attachments:attachments.length?attachments:undefined, _ts:session?.pending_started_at||Date.now()/1000, _pending:true, }; } 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; } if (S.activeStreamId && S.activeStreamId === streamId) 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(t('reconnect_active')); } 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(t('reconnect_finished')); } else { clearInflight(); // completed normally, no banner needed } } } catch(e) { clearInflight(); } } function syncTopbar(){ if(!S.session){ document.title=window._botName||'Hermes'; if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays(); if(typeof syncModelChip==='function') syncModelChip(); if(typeof syncAgentChip==='function') syncAgentChip(); if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions(); else { const sidebarName=$('sidebarWsName'); if(sidebarName && sidebarName.textContent==='Workspace'){ sidebarName.textContent=t('no_workspace'); } } return; } const sessionTitle=S.session.title||t('untitled'); const agentMeta=S.session.agent?(AGENT_META[S.session.agent]||null):null; $('topbarTitle').textContent=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle; document.title=(agentMeta?agentMeta.emoji+' ':'')+sessionTitle+' \u2014 '+(window._botName||'Hermes'); const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); $('topbarMeta').textContent=t('n_messages',vis.length); // If a profile switch just happened, apply its model rather than the session's stale value. // S._pendingProfileModel is set by switchToProfile() and cleared here after one application. const modelOverride=S._pendingProfileModel; let currentModel=S.session.model||''; if(modelOverride){ S._pendingProfileModel=null; _applyModelToDropdown(modelOverride,$('modelSelect')); currentModel=modelOverride; } else { const applied=_applyModelToDropdown(currentModel,$('modelSelect')); // If the model isn't in the current provider list, add it as a visually marked // "(unavailable)" entry so the session value is preserved without misleading the user. // Selecting it will still attempt to send (same as before), but the label makes // clear it's a stale model from a previous session. if(!applied && currentModel){ const opt=document.createElement('option'); opt.value=currentModel; opt.textContent=getModelLabel(currentModel)+t('model_unavailable'); opt.style.color='var(--muted, #888)'; opt.title=t('model_unavailable_title'); $('modelSelect').appendChild(opt); $('modelSelect').value=currentModel; } } if(typeof syncModelChip==='function') syncModelChip(); if(typeof syncAgentChip==='function') syncAgentChip(); // 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'; if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions(); if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays(); // modelSelect already set above // Update profile chip label const profileLabel=$('profileChipLabel'); if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; } 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(); } // ── Message Renderer Helpers ──────────────────────────────────────────────── // Build visible items list from S.messages (shared by renderMessages) function _buildVisibleItems(): {msg: any; rawIdx: number}[] { const result: {msg: any; rawIdx: number}[] = []; let rawIdx = 0; for (const m of S.messages) { if (!m || !m.role || m.role === 'tool') { rawIdx++; continue; } const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0; const hasTu = Array.isArray(m.content) && m.content.some((p: any) => p && p.type === 'tool_use'); if (msgContent(m) || m.attachments?.length || (m.role === 'assistant' && (hasTc || hasTu))) { result.push({ msg: m, rawIdx }); } rawIdx++; } return result; } // Simple DOM-based message renderer — no virtual scroller. // Appends new messages to existing DOM, removes deleted ones. function renderMessages(){ const inner = $('msgInner'); if (!inner) return; const vis = _buildVisibleItems(); $('emptyState').style.display = vis.length ? 'none' : ''; // Build a map of the new rendered rows keyed by rawIdx const newRows = new Map(); for (let vi = 0; vi < vis.length; vi++) { const vitem = vis[vi]; const wrapper = document.createElement('div'); wrapper.style.cssText = 'position:relative;width:100%;'; renderRow(vi, vitem, wrapper); newRows.set(vitem.rawIdx, wrapper as HTMLElement); } // Diff against existing DOM rows to preserve live assistant rows const existing = inner.querySelectorAll('.msg-row[data-msg-idx]'); const existingMap = new Map(); existing.forEach(el => { const idx = parseInt((el as HTMLElement).dataset.msgIdx || '-1'); if (idx >= 0) existingMap.set(idx, el as HTMLElement); }); // Remove rows that are no longer in the message list (but preserve live assistant rows) existing.forEach(el => { const idx = parseInt((el as HTMLElement).dataset.msgIdx || '-1'); const isLive = (el as HTMLElement).dataset.liveAssistant === '1'; if (!newRows.has(idx) && !isLive) el.remove(); }); // Append new rows after the last matching existing row let lastAppended: number | null = null; for (let vi = 0; vi < vis.length; vi++) { const { rawIdx } = vis[vi]; if (existingMap.has(rawIdx)) { lastAppended = rawIdx; continue; } const newEl = newRows.get(rawIdx)!; if (lastAppended === null) { inner.prepend(newEl); } else { const anchor = existingMap.get(lastAppended); if (anchor && anchor.nextElementSibling) { anchor.parentElement!.insertBefore(newEl, anchor.nextElementSibling); } else { inner.appendChild(newEl); } } lastAppended = rawIdx; } // Preserve live assistant rows const liveRows = inner.querySelectorAll('[data-live-assistant="1"]'); liveRows.forEach(lr => { if (!inner.contains(lr as Node)) inner.appendChild(lr as Node); }); // Render usage badge on last assistant message if(window._showTokenUsage && S.session && (S.session.input_tokens || S.session.output_tokens)){ const rows = inner.querySelectorAll('.msg-row'); let lastAssist = null; for(let i = rows.length - 1; i >= 0; i--){ if(rows[i].dataset.role === 'assistant'){ lastAssist = rows[i]; break; } } if(lastAssist && !lastAssist.querySelector('.msg-usage')){ const msg = S.messages && S.messages.length ? [...S.messages].reverse().find(m => m.role === 'assistant') : null; const usage = document.createElement('div'); usage.className = 'msg-usage'; const u = (msg && msg._usage) || {}; const inTok = u.in || 0; const outTok = u.out || 0; const cost = S.session.estimated_cost; const inSpan = Object.assign(document.createElement('span'), { className: 'usage-in', textContent: _fmtTokens(inTok) }); const sep1 = Object.assign(document.createElement('span'), { className: 'usage-sep', textContent: ' in · ' }); const outSpan = Object.assign(document.createElement('span'), { className: 'usage-out', textContent: _fmtTokens(outTok) }); usage.appendChild(inSpan); usage.appendChild(sep1); usage.appendChild(outSpan); if(cost !== undefined && cost !== null){ const sep2 = Object.assign(document.createElement('span'), { className: 'usage-sep', textContent: ' · ' }); const costSpan = Object.assign(document.createElement('span'), { className: 'usage-cost', textContent: `~$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` }); usage.appendChild(sep2); usage.appendChild(costSpan); } lastAssist.appendChild(usage); } } _syncSessionTotalInFooter(S.session); scrollToBottom(); requestAnimationFrame(() => { highlightCode(); addCopyButtons(); renderMermaidBlocks(); renderKatexBlocks(); }); } // Render a single message row into `inner` (used by renderMessages) // vi = visible index, vitem = {msg, rawIdx} function renderRow(vi: number, vitem: {msg: any; rawIdx: number}, inner: HTMLElement) { const { msg: m, rawIdx } = vitem; let content = m.content||''; let thinkingText = ''; if (Array.isArray(content)) { thinkingText = content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n'); content = content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); } if (!thinkingText && m.reasoning) thinkingText = m.reasoning; if (!thinkingText && typeof content==='string'){ const thinkMatch=content.match(/([\s\S]*?)<\/think>/); if(thinkMatch){ thinkingText=thinkMatch[1].trim(); content=content.replace(/[\s\S]*?<\/think>\s*/,'').trimStart(); } if(!thinkingText){ const gemmaMatch=content.match(/<\|channel>thought\n([\s\S]*?)/); if(gemmaMatch){ thinkingText=gemmaMatch[1].trim(); content=content.replace(/<\|channel>thought\n[\s\S]*?\s*/,'').trimStart(); } } } const isUser=m.role==='user'; const isSystem=m.role==='system'; const vis = _buildVisibleItems(); const isLastAssistant=!isUser&&vi===vis.length-1; if(thinkingText&&!isUser&&!isSystem){ const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row'; thinkRow.innerHTML=`
      ${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
      ${esc(thinkingText)}
      `; inner.appendChild(thinkRow); } const prevRole = vi > 0 ? vis[vi - 1].msg.role : null; const isGrouped = prevRole === m.role; const row=document.createElement('div');row.className='msg-row' + (isGrouped ? ' msg-grouped' : ''); row.dataset.msgIdx=String(rawIdx);row.dataset.role=String(m.role||'assistant'); if(m._live) row.setAttribute('data-live-assistant','1'); if(isSystem){ const sysHtml=`
      ${renderMd(String(content||''))}
      `; row.innerHTML=sysHtml; row.dataset.rawText=String(content||'').trim(); inner.appendChild(row); return; } let filesHtml=''; if(m.attachments&&m.attachments.length) filesHtml=`
      ${m.attachments.map(f=>`
      ${li('paperclip',12)} ${esc(f)}
      `).join('')}
      `; const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
      ') : renderMd(String(content)); const editBtn = isUser ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; const tsVal=m._ts||m.timestamp; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; const _bn=window._botName||'Hermes'; const _userEmoji=window._userEmoji||'🙂'; const _userName=window._userName||'You'; const sessionAgent = !isUser && typeof S !== 'undefined' && S.session && S.session.agent; const effectiveAgent = !isSystem && !isUser ? (m.agent || sessionAgent || null) : null; const agentMeta = effectiveAgent ? (AGENT_META[effectiveAgent] || null) : null; const showIcon = isUser ? _userEmoji : (agentMeta ? agentMeta.emoji : esc(_bn.charAt(0).toUpperCase())); const showName = isUser ? esc(_userName) : (agentMeta ? agentMeta.name : esc(_bn)); const agentColor = !isUser && agentMeta ? (effectiveAgent === 'rose' ? '#f44336' : effectiveAgent === 'lotus' ? '#e91e63' : effectiveAgent === 'forget-me-not' ? '#ff9800' : '#888') : null; const sessionModel = (typeof S !== 'undefined' && S.session && S.session.model) ? S.session.model : ($('modelSelect')?.value || ''); const modelBadge = !isUser && (m.model || sessionModel) ? `${esc(m.model || sessionModel)}` : ''; row.innerHTML=`
      ${agentMeta&&!isUser?agentMeta.emoji:showIcon}
      ${showName}${agentMeta&&!isUser?`via ${agentMeta.name}`:''}${modelBadge}${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); } // ── Tool Card Sync ─────────────────────────────────────────────────────────── function _syncToolCards() { const inner = $('msgInner'); if (!inner || S.busy || !S.toolCalls?.length) return; // Remove existing tool card rows inner.querySelectorAll('.tool-card-row, .tool-cards-toggle').forEach(el => el.remove()); // Group tool calls by their assistant message index const byAssistant: Record = {}; for (const tc of S.toolCalls) { const key = tc.assistant_msg_idx ?? -1; (byAssistant[key] ??= []).push(tc); } const allRows: HTMLElement[] = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]')) as unknown as HTMLElement[]; const anchorNext = new Map(); for (const [key, cards] of Object.entries(byAssistant)) { const aIdx = parseInt(key); let anchorRow: HTMLElement | null = null; // Find anchor row by exact msgIdx match if (aIdx >= 0) { anchorRow = allRows.find(r => parseInt(r.dataset.msgIdx || '-1') === aIdx) ?? null; } // Fallback: last assistant row at or before aIdx if (!anchorRow) { for (let i = allRows.length - 1; i >= 0; i--) { const ri = parseInt(allRows[i].dataset.msgIdx || '-1'); if (ri >= 0 && ri <= aIdx) { anchorRow = allRows[i] as HTMLElement; break; } } } if (!anchorRow) continue; // Build tool cards fragment const frag = document.createDocumentFragment(); for (const tc of cards) frag.appendChild(buildToolCard(tc) as unknown as Node); // Add expand/collapse for 2+ cards if (cards.length >= 2) { const toggle = document.createElement('div'); toggle.className = 'tool-cards-toggle'; const cardEls = Array.from(frag.querySelectorAll('.tool-card')); const exp = document.createElement('button'); exp.textContent = t('expand_all'); exp.onclick = () => cardEls.forEach(c => c.classList.add('open')); const col = document.createElement('button'); col.textContent = t('collapse_all'); col.onclick = () => cardEls.forEach(c => c.classList.remove('open')); toggle.appendChild(exp); toggle.appendChild(col); frag.insertBefore(toggle as unknown as Node, frag.firstChild); } // Insert after anchor row const insertAfter = anchorNext.get(anchorRow) ?? anchorRow; if (insertAfter.nextSibling) { insertAfter.parentNode!.insertBefore(frag, insertAfter.nextSibling); } else { insertAfter.parentNode!.appendChild(frag); } anchorNext.set(anchorRow, frag.lastChild as HTMLElement); } } function toolIcon(name){ const icons={ terminal: li('terminal'), read_file: li('file-text'), write_file: li('file-pen'), search_files: li('search'), web_search: li('globe'), web_extract: li('globe'), execute_code: li('play'), patch: li('wrench'), memory: li('brain'), skill_manage: li('book-open'), todo: li('list-todo'), cronjob: li('clock'), delegate_task: li('bot'), send_message: li('message-square'), browser_navigate:li('globe'), vision_analyze: li('eye'), subagent_progress:li('shuffle'), }; return icons[name]||li('wrench'); } 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?'':''; const durationBadge=tc.duration!=null?`${tc.duration.toFixed(1)}s`:''; const isSubagent=tc.name==='subagent_progress'; const isDelegation=tc.name==='delegate_task'; // Show only active (running) tools when there are running tools const hasRunningTools = S.toolCalls.some(t=>!t.done); const isHidden = tc.done===true && hasRunningTools && !isSubagent; const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'')+(isHidden?' hidden':''); // Clean up legacy subagent prefixes since the Lucide icon already shows it let displayName=tc.name; if(isSubagent) displayName='Subagent'; if(isDelegation) displayName='Delegate task'; let previewText=tc.preview||displaySnippet||''; if(isSubagent) previewText=previewText.replace(/^(?:\u{1F500}|↳)\s*/u,''); row.innerHTML=`
      ${runIndicator} ${icon} ${esc(displayName)} ${esc(previewText)} ${durationBadge} ${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.parentElement!.insertBefore(bar as unknown as Node, ta.nextSibling as Node | null); 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') as unknown as HTMLElement).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(t('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(t('regen_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; // Handle pre > code (normal Prism blocks) el.querySelectorAll('pre > code').forEach(codeEl=>{ const pre=codeEl.parentElement; if(pre.querySelector('.code-copy-btn')) return; const btn=makeCopyBtn(codeEl.textContent||''); 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); } }); // Handle bare pre without code child (e.g. thinking-card content, plain text) el.querySelectorAll('pre:not(:has(.code-copy-btn))').forEach(pre=>{ if(pre.querySelector('.code-copy-btn')) return; const btn=makeCopyBtn(pre.textContent||''); pre.style.position='relative'; btn.style.cssText='position:absolute;top:6px;right:6px;'; pre.appendChild(btn); }); } function makeCopyBtn(text){ const btn=document.createElement('button'); btn.className='code-copy-btn'; btn.textContent=t('copy'); btn.onclick=(e)=>{ e.stopPropagation(); navigator.clipboard.writeText(text).then(()=>{ btn.textContent=t('copied'); setTimeout(()=>{btn.textContent=t('copy');},1500); }); }; return 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)}
      `; } }); } let _katexLoading=false; let _katexReady=false; function renderKatexBlocks(){ const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])'); if(!blocks.length) return; if(!_katexReady){ if(!_katexLoading){ _katexLoading=true; const script=document.createElement('script'); script.src='https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js'; script.integrity='sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6'; script.crossOrigin='anonymous'; script.onload=()=>{ if(typeof katex!=='undefined'){ _katexReady=true; renderKatexBlocks(); } }; document.head.appendChild(script); } return; } blocks.forEach(el=>{ el.dataset.rendered='true'; const src=el.textContent||''; const displayMode=el.dataset.katex==='display'; try{ katex.render(src,el,{ displayMode, throwOnError:false, trust:false, strict:'ignore', }); }catch(e){ // Leave as raw text in a code span on failure el.outerHTML=`${esc(src)}`; } }); } function _thinkingMarkup(text='') { // Use session agent info if available (mirrors renderOneMsg logic) const sessionAgent = typeof S !== 'undefined' && S.session && S.session.agent; const agentMeta = sessionAgent ? (AGENT_META[sessionAgent] || null) : null; const icon = agentMeta ? agentMeta.emoji : esc((window._botName || 'Hermes').charAt(0).toUpperCase()); const label = agentMeta ? agentMeta.name : esc(window._botName || 'Hermes'); const agentColor = agentMeta ? (sessionAgent === 'rose' ? '#f44336' : sessionAgent === 'lotus' ? '#e91e63' : sessionAgent === 'forget-me-not' ? '#ff9800' : '#888') : null; const hasText = text && String(text).trim(); const body = hasText ? `
      ${li('lightbulb',14)}${t('thinking')}
      ${esc(String(text).trim())}
      ` : `
      `; return `
      ${icon}
      ${label}
      ${body}`; } function appendThinking(text=''){ $('emptyState').style.display='none'; let row=$('thinkingRow'); if(!row){ row=document.createElement('div'); row.className='msg-row'; row.id='thinkingRow'; $('msgInner').appendChild(row); } row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row'; row.innerHTML=_thinkingMarkup(text); scrollToBottom(); } function updateThinking(text=''){appendThinking(text);} function removeThinking(){const el=$('thinkingRow');if(el)el.remove();} function fileIcon(name, type){ if(type==='dir') return li('folder',14); const e=fileExt(name); if(IMAGE_EXTS.has(e)) return li('image',14); if(MD_EXTS.has(e)) return li('file-text',14); if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return li('download',14); if(e==='.py') return li('file-code',14); if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return li('zap',14); if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return li('settings',14); if(e==='.sh'||e==='.bash') return li('terminal',14); if(e==='.pdf') return li('download',14); return li('file-text',14); } function renderBreadcrumb(){ const bar=$('breadcrumbBar'); const upBtn=$('btnUpDir'); if(!bar)return; if(S.currentDir==='.'){ bar.style.display='none'; if(upBtn)upBtn.style.display='none'; return; } bar.style.display='flex'; if(upBtn)upBtn.style.display=''; bar.innerHTML=''; // Root segment const root=document.createElement('span'); root.className='breadcrumb-seg breadcrumb-link'; root.textContent='~'; root.onclick=()=>loadDir('.'); bar.appendChild(root); // Path segments const parts=S.currentDir.split('/'); let accumulated=''; for(let i=0;iloadDir(target); } else { seg.className='breadcrumb-seg breadcrumb-current'; } bar.appendChild(seg); } } // Track expanded directories for tree view if(!S._expandedDirs) S._expandedDirs=new Set(); // Cache of fetched directory contents: path -> entries[] if(!S._dirCache) S._dirCache={}; function renderFileTree(){ const box=$('fileTree');box.innerHTML=''; // Cache current dir entries S._dirCache[S.currentDir||'.']=S.entries; _renderTreeItems(box, S.entries, 0); } function _renderTreeItems(container, entries, depth){ for(const item of entries){ const el=document.createElement('div');el.className='file-item'; el.style.paddingLeft=(8+depth*16)+'px'; if(item.type==='dir'){ // Toggle arrow for directories const arrow=document.createElement('span'); arrow.className='file-tree-toggle'; const isExpanded=S._expandedDirs.has(item.path); arrow.textContent=isExpanded?'\u25BE':'\u25B8'; el.appendChild(arrow); } // Icon const iconEl=document.createElement('span'); iconEl.className='file-icon';iconEl.innerHTML=fileIcon(item.name,item.type); el.appendChild(iconEl); // Name const nameEl=document.createElement('span'); nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename'); nameEl.ondblclick=(e)=>{ e.stopPropagation(); // For directories, double-click navigates (breadcrumb view) if(item.type==='dir'){loadDir(item.path);return;} 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; 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(t('renamed_to')+newName); // Invalidate cache and re-render delete S._dirCache[S.currentDir]; await loadDir(S.currentDir); }catch(err){showToast(t('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 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 if(item.type==='file'){ const del=document.createElement('button'); del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7'; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; el.appendChild(del); } if(item.type==='dir'){ // Single-click toggles expand/collapse el.onclick=async(e)=>{ e.stopPropagation(); if(S._expandedDirs.has(item.path)){ S._expandedDirs.delete(item.path); if(typeof _saveExpandedDirs==='function')_saveExpandedDirs(); renderFileTree(); }else{ S._expandedDirs.add(item.path); if(typeof _saveExpandedDirs==='function')_saveExpandedDirs(); // Fetch children if not cached if(!S._dirCache[item.path]){ try{ const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`); S._dirCache[item.path]=data.entries||[]; }catch(e2){S._dirCache[item.path]=[];} } renderFileTree(); } }; }else{ el.onclick=async()=>openFile(item.path); } container.appendChild(el); // Render children if directory is expanded if(item.type==='dir'&&S._expandedDirs.has(item.path)){ const children=S._dirCache[item.path]||[]; if(children.length){ _renderTreeItems(container, children, depth+1); }else{ const empty=document.createElement('div'); empty.className='file-item file-empty'; empty.style.paddingLeft=(8+(depth+1)*16)+'px'; empty.textContent=t('empty_dir'); container.appendChild(empty); } } } } async function deleteWorkspaceFile(relPath, name){ if(!S.session)return; const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true}); if(!_delFile) return; try{ await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); showToast(t('deleted')+name); // Close preview if we just deleted the viewed file if($('previewPathText').textContent===relPath)($('btnClearPreview') as unknown as {onclick:()=>void}).onclick(); await loadDir(S.currentDir); }catch(e){setStatus(t('delete_failed')+e.message);} } async function promptNewFile(){ if(!S.session)return; const _name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')}); const name=_name as string | null; if(!name||!name.trim())return; const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); try{ await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})}); showToast(t('created')+name.trim()); await loadDir(S.currentDir); openFile(relPath); }catch(e){setStatus(t('create_failed')+e.message);} } async function promptNewFolder(){ if(!S.session)return; const _name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')}); const name=_name as string | null; if(!name||!name.trim())return; const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); try{ await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); showToast(t('folder_created')+name.trim()); await loadDir(S.currentDir); }catch(e){setStatus(t('folder_create_failed')+e.message);} } function renderTray(){ const tray=$('attachTray');tray.innerHTML=''; if(!S.pendingFiles.length){tray.classList.remove('has-files');updateSendBtn();return;} tray.classList.add('has-files'); updateSendBtn(); S.pendingFiles.forEach((f,i)=>{ const chip=document.createElement('div');chip.className='attach-chip'; chip.innerHTML=`${li('paperclip',12)} ${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(t('all_uploads_failed',total)); return names; } // Update user avatar preview in Settings function updateUserAvatarPreview(emoji){ const preview=$('userAvatarPreview'); if(preview) preview.textContent=emoji||'🙂'; }