const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'}; 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||''; // 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. const fence_stash=[]; s=s.replace(/(```[\s\S]*?```|`[^`\n]+`)/g,m=>{fence_stash.push(m);return '\x00F'+(fence_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 h=lang?`
${esc(lang)}
`:'';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){ t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/`([^`\n]+)`/g,(_,x)=>`${esc(x)}`); t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`${esc(lb)}`); // Escape any plain text that isn't already wrapped in a tag we produced // by escaping bare < > that aren't part of our own tags const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } 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)=>`

${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+'
'; }); 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}
`; }); // Escape any remaining HTML tags that are NOT from our own markdown output. // Our pipeline only emits: ,,,
,,