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 SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns // Tracks which session's queue to drain in setBusy(false). // Set to activeSid just before setBusy(false) in done/error handlers so the // queue drains the session that *finished*, not the one currently viewed. // Single-shot: setBusy() reads and clears this on every call. Concurrent // back-to-back stream completions would overwrite it, but HTTPServer is // single-threaded so only one done event fires at a time in practice. let _queueDrainSid=null; const $=id=>document.getElementById(id); // Redirect to /login when the server responds with 401 (auth session expired). // Handles iOS PWA standalone mode where a server-side 302→/login would break // out of the PWA shell into Safari instead of navigating within it. function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;} 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); // Stamp created_at so the restore path can detect stale entries (agent already responded) const entry={...payload, _queued_at: Date.now()}; q.push(entry); // Persist to sessionStorage so the queue survives page refresh try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){} 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]; try{ sessionStorage.removeItem('hermes-queue-'+sid); }catch(_){} } else { try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){} } return next; } function getQueuedSessionCount(sid){ return _getSessionQueue(sid,false).length; } function _compressionSessionLock(){ return window._compressionLockSid||null; } function _setCompressionSessionLock(sid){ window._compressionLockSid=sid||null; } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); /* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */ function _openImgLightbox(src, alt) { const lb = document.createElement('div'); lb.className = 'img-lightbox'; lb.setAttribute('role', 'dialog'); lb.setAttribute('aria-label', alt || 'Image'); const img = document.createElement('img'); img.src = src; img.alt = alt || ''; img.onclick = e => e.stopPropagation(); const cls = document.createElement('button'); cls.className = 'img-lightbox-close'; cls.setAttribute('aria-label', 'Close'); cls.textContent = '×'; cls.onclick = () => _closeImgLightbox(lb); lb.appendChild(img); lb.appendChild(cls); lb.onclick = () => _closeImgLightbox(lb); document.body.appendChild(lb); // Close on Escape lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); }; document.addEventListener('keydown', lb._escHandler); } function _closeImgLightbox(lb) { if(!lb || !lb.parentNode) return; document.removeEventListener('keydown', lb._escHandler); lb.style.animation = 'lb-in .12s ease reverse'; setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120); } document.addEventListener('click', e => { const img = e.target && e.target.closest ? e.target.closest('.msg-media-img') : null; if(!img) return; _openImgLightbox(img.src, img.alt); }); const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i; const _PDF_EXTS=/\.pdf$/i; const _HTML_EXTS=/\.(html?|htm)$/i; const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i; const _SVG_EXTS=/\.svg$/i; const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i; const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i; const _CSV_EXTS=/\.csv$/i; const _EXCALIDRAW_EXTS=/\.excalidraw$/i; // ── Media playback speed controls ───────────────────────────────────────── const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2]; const MEDIA_PLAYBACK_STORAGE_KEY='hermes-media-playback-rate'; function _getStoredMediaPlaybackRate(){ try{ const raw=localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY); const rate=Number(raw); return MEDIA_PLAYBACK_RATES.includes(rate)?rate:1; }catch(_){return 1;} } function _setStoredMediaPlaybackRate(rate){ if(!MEDIA_PLAYBACK_RATES.includes(rate)) return; try{localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY,String(rate));}catch(_){} } function _syncMediaSpeedButtons(editor, rate){ if(!editor) return; editor.querySelectorAll('.media-speed-btn').forEach(b=>{ const active=Number(b.dataset.rate)===rate; b.classList.toggle('active',active); b.setAttribute('aria-pressed',active?'true':'false'); }); } function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){ if(!media) return; media.playbackRate=rate; _syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate); } function _mediaKindForName(name=''){ const clean=String(name||'').split('?')[0].toLowerCase(); if(_AUDIO_EXTS.test(clean)) return 'audio'; if(_VIDEO_EXTS.test(clean)) return 'video'; if(_IMAGE_EXTS.test(clean)) return 'image'; return ''; } function _mediaSpeedControlsHtml(kind, label){ const safeLabel=esc(label||kind||'media'); const current=_getStoredMediaPlaybackRate(); return `
${MEDIA_PLAYBACK_RATES.map(rate=>``).join('')}
`; } function _mediaPlayerHtml(kind, src, name, extra=''){ const safeName=esc(name||'media'); const safeSrc=esc(src); const tag=kind==='video' ? `` : ``; return `
${tag}
${safeName}${extra}
${_mediaSpeedControlsHtml(kind,safeName)}
`; } function _renderAttachmentHtml(fname, url){ const kind=_mediaKindForName(fname); if(kind==='image') return `${esc(fname)}`; if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname); return `
${li('paperclip',12)} ${esc(fname)}
`; } document.addEventListener('click', e => { const btn=e.target&&e.target.closest?e.target.closest('.media-speed-btn'):null; if(!btn) return; const editor=btn.closest('.msg-media-editor,.preview-media-wrap'); if(!editor) return; const media=editor.querySelector('audio,video'); if(!media) return; const rate=Number(btn.dataset.rate)||1; _setStoredMediaPlaybackRate(rate); _applyMediaPlaybackRate(media,rate); }); document.addEventListener("loadedmetadata", e=>{ if(e.target&&e.target.matches&&e.target.matches('.msg-media-player,audio,video')){ _applyMediaPlaybackRate(e.target); } },true); function _initMediaPlaybackObserver(){ if(!document.body||window._mediaPlaybackObserver) return; window._mediaPlaybackObserver=new MutationObserver(records=>{ for(const rec of records){ for(const node of rec.addedNodes||[]){ if(!node||node.nodeType!==1) continue; const media=[]; if(node.matches&&node.matches('audio,video')) media.push(node); if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video')); media.forEach(m=>_applyMediaPlaybackRate(m)); } } }); window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true}); document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m)); } if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver); else _initMediaPlaybackObserver(); setTimeout(_initMediaPlaybackObserver,0); // Dynamic model labels -- populated by populateModelDropdown(), fallback to static map let _dynamicModelLabels={}; window._configuredModelBadges=window._configuredModelBadges||{}; // ── Smart model resolver ──────────────────────────────────────────────────── // Finds the best matching option value in a `; const _si=_searchRow.querySelector('.model-search-input'); const _sc=_searchRow.querySelector('.model-search-clear'); // Create custom model section elements const _custSep=document.createElement('div'); _custSep.className='model-group model-custom-sep'; _custSep.textContent=t('model_custom_label')||'Custom model ID'; const _custRow=document.createElement('div'); _custRow.className='model-custom-row'; _custRow.innerHTML=``; const _ci=_custRow.querySelector('.model-custom-input'); const _cb=_custRow.querySelector('.model-custom-btn'); const _configuredRank=(badge)=>{ if(!badge) return Number.POSITIVE_INFINITY; if(badge.role==='primary') return 0; if(badge.role==='fallback'){ const m=String(badge.label||'').match(/fallback\s+(\d+)/i); return m?Number(m[1]):999; } return 500; }; // Filter function (defined AFTER _searchRow and _cust* are created) const _filterModels=(term)=>{ term=term.trim().toLowerCase(); const found=new Set(); for(const m of _modelData){ const name=m.name.toLowerCase(); const id=m.id.toLowerCase(); if(name.includes(term)||id.includes(term)){ found.add(m.value); } } const matches=(m)=>!term||found.has(m.value); const configuredModels=_modelData .filter(m=>m.badge&&matches(m)) .sort((a,b)=>{ const configuredRankA=_configuredRank(a.badge); const configuredRankB=_configuredRank(b.badge); if(configuredRankA!==configuredRankB) return configuredRankA-configuredRankB; return a.name.localeCompare(b.name); }); const configuredIds=new Set(configuredModels.map(m=>m.value)); // Clear and rebuild dd.innerHTML=''; // Add search and custom elements first (CRITICAL: must be before models) dd.appendChild(_scopeNote); dd.appendChild(_searchRow); dd.appendChild(_custSep); dd.appendChild(_custRow); if(configuredModels.length){ const configuredHeading=document.createElement('div'); configuredHeading.className='model-group'; configuredHeading.textContent=t('model_group_configured')||'Configured'; dd.appendChild(configuredHeading); for(const m of configuredModels){ const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; row.innerHTML=`
${m.name}${badgeHtml}
${m.id}`; row.onclick=()=>selectModelFromDropdown(m.value); dd.appendChild(row); } } // Add remaining models matching filter let _lastGroup=null; for(const m of _modelData){ if(configuredIds.has(m.value)||!matches(m)) continue; if(m.group&&m.group!==_lastGroup){ const heading=document.createElement('div'); heading.className='model-group'; heading.textContent=m.group; dd.appendChild(heading); _lastGroup=m.group; } const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; row.innerHTML=`
${m.name}${badgeHtml}
${m.id}`; row.onclick=()=>selectModelFromDropdown(m.value); dd.appendChild(row); } // Show "No results" if filtered and nothing matched if(term&&found.size===0){ const noResult=document.createElement('div'); noResult.className='model-search-no-results'; noResult.textContent=t('model_search_no_results')||'No models found'; noResult.style.padding='12px 14px'; noResult.style.color='var(--muted)'; noResult.style.textAlign='center'; dd.appendChild(noResult); } // Restore focus to search input _si.focus(); }; // Event handlers for search input _si.addEventListener('input',()=>_filterModels(_si.value)); _si.addEventListener('keydown',e=>{if(e.key==='Enter') {e.preventDefault();}if(e.key==='Escape') {closeModelDropdown();}}); _si.addEventListener('click',e=>e.stopPropagation()); // Event handlers for clear button _sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); }; _sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }}); // Event handlers for custom input const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.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()); // Add search and custom elements to dropdown (initial render) dd.appendChild(_scopeNote); dd.appendChild(_searchRow); dd.appendChild(_custSep); dd.appendChild(_custRow); // Apply initial filter (empty shows all) _filterModels(''); } async function selectModelFromDropdown(value){ const sel=$('modelSelect'); 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=getModelLabel(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') await sel.onchange(); } 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(); if(typeof closeReasoningDropdown==='function') closeReasoningDropdown(); 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=>{ if(!e.target.closest('#composerModelChip') && !e.target.closest('#composerModelDropdown')) closeModelDropdown(); }); window.addEventListener('resize',()=>{ const dd=$('composerModelDropdown'); if(dd&&dd.classList.contains('open')) _positionModelDropdown(); // Keep the reasoning dropdown aligned under its chip when the window // resizes while open — same pattern as the model dropdown above. const rdd=$('composerReasoningDropdown'); if(rdd&&rdd.classList.contains('open')&&typeof _positionReasoningDropdown==='function'){ _positionReasoningDropdown(); } }); // ── Reasoning effort chip ──────────────────────────────────────────────────── let _currentReasoningEffort=null; function _normalizeReasoningEffort(eff){ return String(eff||'').trim().toLowerCase(); } function _formatReasoningEffortLabel(effort){ if(effort==='none') return 'None'; if(!effort) return 'Default'; return effort; } function _applyReasoningChip(eff){ const effort=_normalizeReasoningEffort(eff); _currentReasoningEffort=effort; const wrap=$('composerReasoningWrap'); const label=$('composerReasoningLabel'); const chip=$('composerReasoningChip'); if(!wrap||!label) return; wrap.style.display=''; label.textContent=_formatReasoningEffortLabel(effort); if(chip){ const inactive=!effort||effort==='none'; chip.classList.toggle('inactive',inactive); chip.title='Reasoning effort: '+_formatReasoningEffortLabel(effort); } _highlightReasoningOption(effort); } function fetchReasoningChip(){ api('/api/reasoning').then(function(st){ _applyReasoningChip((st&&st.reasoning_effort)||''); }).catch(function(){_applyReasoningChip('');}); } function syncReasoningChip(){ if(_currentReasoningEffort===null){fetchReasoningChip();return;} _applyReasoningChip(_currentReasoningEffort); } function _highlightReasoningOption(effort){ const dd=$('composerReasoningDropdown'); if(!dd) return; dd.querySelectorAll('.reasoning-option').forEach(function(opt){ opt.classList.toggle('selected',opt.dataset.effort===effort); }); } function toggleReasoningDropdown(){ const dd=$('composerReasoningDropdown'); const chip=$('composerReasoningChip'); if(!dd||!chip) return; const open=dd.classList.contains('open'); if(open){closeReasoningDropdown();return;} if(typeof closeProfileDropdown==='function') closeProfileDropdown(); if(typeof closeWsDropdown==='function') closeWsDropdown(); closeModelDropdown(); _highlightReasoningOption(_currentReasoningEffort); dd.classList.add('open'); _positionReasoningDropdown(); chip.classList.add('active'); } function _positionReasoningDropdown(){ const dd=$('composerReasoningDropdown'); const chip=$('composerReasoningChip'); const footer=document.querySelector('.composer-footer'); if(!dd||!chip||!footer) return; const chipRect=chip.getBoundingClientRect(); const footerRect=footer.getBoundingClientRect(); let left=chipRect.left-footerRect.left; const maxLeft=Math.max(0,footer.clientWidth-dd.offsetWidth); left=Math.max(0,Math.min(left,maxLeft)); dd.style.left=`${left}px`; } function closeReasoningDropdown(){ const dd=$('composerReasoningDropdown'); const chip=$('composerReasoningChip'); if(dd) dd.classList.remove('open'); if(chip) chip.classList.remove('active'); } document.addEventListener('click',function(e){ if(!e.target.closest('#composerReasoningChip')&&!e.target.closest('#composerReasoningDropdown')) closeReasoningDropdown(); if(e.target.closest('.reasoning-option')){ const opt=e.target.closest('.reasoning-option'); const effort=opt&&opt.dataset.effort; if(effort){ api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})}) .then(function(st){ _applyReasoningChip((st&&st.reasoning_effort)||effort); showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)); }) .catch(function(){showToast('🧠 Failed to set effort');}); closeReasoningDropdown(); } } }); // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. // Once the user scrolls back to within 150px 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<150; _scrollPinned=nearBottom; const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; // Load older messages when scrolled near the top if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){ _loadOlderMessages(); } }); })(); 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; const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0); const ctxWindow=usage.context_length||0; const cost=usage.estimated_cost; // Show indicator whenever we have any usage data (tokens or cost) if(!promptTok&&!totalTok&&!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); // ── Compress affordance (#524) ── // Show a hint in the tooltip when context usage is high so users // discover /compress without having to know the slash command. const compressWrap=$('ctxTooltipCompress'); const compressBtn=$('ctxCompressBtn'); if(compressWrap&&compressBtn){ if(pct>=75){ compressWrap.style.display=''; compressBtn.textContent=t('ctx_compress_action'); compressBtn.onclick=function(){ const ta=$('msg'); if(ta){ta.value='/compress ';ta.focus();autoResize();} }; }else if(pct>=50){ compressWrap.style.display=''; compressBtn.textContent=t('ctx_compress_hint'); compressBtn.onclick=function(){ const ta=$('msg'); if(ta){ta.value='/compress ';ta.focus();autoResize();} }; }else{ compressWrap.style.display='none'; } } let label=hasCtxWindow?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`; 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)`:`${_fmtTokens(totalTok)} tokens used`; if(tokensLine) tokensLine.textContent=hasCtxWindow?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`; 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=''; } } } // ── Touch support: toggle context tooltip on tap (#524) ── // On mobile, hover doesn't work — allow tap on the context ring button // to toggle the tooltip visibility so the compress affordance is reachable. document.addEventListener('DOMContentLoaded',function(){ const wrap=document.getElementById('ctxIndicatorWrap'); const tooltip=document.getElementById('ctxTooltip'); if(!wrap||!tooltip)return; const btn=document.getElementById('ctxIndicator'); if(!btn)return; btn.addEventListener('click',function(e){ e.stopPropagation(); const isOpen=tooltip.classList.contains('ctx-tooltip-active'); tooltip.classList.toggle('ctx-tooltip-active',!isOpen); tooltip.setAttribute('aria-hidden',String(isOpen)); }); // Close on outside tap document.addEventListener('click',function(){ tooltip.classList.remove('ctx-tooltip-active'); tooltip.setAttribute('aria-hidden','true'); },{passive:true}); // Prevent tooltip click from closing itself tooltip.addEventListener('click',function(e){e.stopPropagation();}); }); 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; const btn=$('scrollToBottomBtn'); if(btn) btn.style.display='none'; } function _fmtOllamaLabel(mid){ const [namePart, ...variantParts] = mid.split(':'); const variant = variantParts.join(':'); const _fmt = (s) => { const tokens = s.replace(/[-_]/g, ' ').split(' '); return tokens.map(t => { const alphaOnly = t.replace(/\./g, ''); if (t.length <= 3 && /^[a-zA-Z.]+$/.test(t)) return t.toUpperCase(); if (/^\d/.test(alphaOnly)) return t.toUpperCase(); return t.charAt(0).toUpperCase() + t.slice(1); }).join(' '); }; let label = _fmt(namePart); if (variant) label += ' (' + _fmt(variant) + ')'; return label; } 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-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'}; if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; // Safe Ollama-tag fallback formatter before generic split('/').pop() let _last = modelId.split('/').pop() || modelId; // Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6) if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':'); const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last); // Narrow: only apply Ollama formatter to IDs with explicit @ollama prefix or colon-tag format. // Avoids reformatting bare provider model IDs like claude-sonnet-4-6 or gpt-4o. const looksLikeBareOllamaId = modelId.startsWith('@ollama') || looksLikeOllamaTag; const ollamaLabel = _fmtOllamaLabel(_last); if ((modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) { return ollamaLabel; } return _last || 'Unknown'; } function _stripXmlToolCallsDisplay(s){ // Strip ... blocks emitted by DeepSeek and // similar models in their raw response text. These are processed separately // as tool calls; leaving them in the content causes them to render visibly // in the settled chat bubble. (#702) // Also handles DSML-prefixed variants from DeepSeek/Bedrock, including // spacing variants like "<|DSML |function_calls" and truncated prefixes. if(!s) return s; const lo=String(s).toLowerCase(); if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s; // Support both plain and DSML-prefixed variants. s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,''); // Also remove truncated opening tags (missing closing ">" at stream tail). s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls(?:>|$)[\s\S]*$/i,''); // Remove malformed DSML tag fragments like "<|DSML |" that can leak in tokens. s=s.replace(/<\s*|\s*DSML\s*[||]\s*/gi,''); return s.trim(); } function _sanitizeThinkingDisplayText(text){ const stripped=_stripXmlToolCallsDisplay(String(text||'')); return stripped.trim(); } function renderMd(raw){ let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n'); // ── Entity decode: must run FIRST so > lines become > for the blockquote // pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this // a blockquote sent as "> text" would never be recognised as a blockquote. s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'"); // ── Blockquote pre-pass (must run BEFORE every other markdown pass) ──────── // Group consecutive >-prefixed lines, strip the > prefix from each line, // recursively render the stripped content with the full pipeline, and // replace the group with a stash token. This is the only way fenced code, // headings, hr, and ordered lists inside a blockquote can render correctly: // the per-line passes downstream don't know about > prefixes, and by the // time the blockquote handler used to run those passes had already mangled // the >-prefixed lines. // // Walks lines (instead of using a single regex) so >-prefixed lines that // sit inside a non-blockquote fenced block (e.g. a shell prompt in a // ```bash``` example) are not miscaptured as a blockquote. const _bq_stash=[]; s=(function _applyBlockquotes(input){ const lines=input.split('\n'); const out=[]; let inFence=false; // inside a non-blockquote ```...``` fence let bqStart=-1; const flush=(end)=>{ if(bqStart<0) return; // Strip "> " prefix (and bare ">" → empty) from each line const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n'); // Recursive call: full pipeline on stripped content. Handles fenced // code, headings, hr, ordered/unordered lists, nested blockquotes // (>>) — anything that renderMd handles at the top level. const rendered=renderMd(stripped); _bq_stash.push('
'+rendered+'
'); // Surround the token with blank lines so the paragraph splitter // isolates it as its own chunk (otherwise the token gets wrapped // in

...
with adjacent text, producing invalid HTML). out.push(''); out.push('\x00Q'+(_bq_stash.length-1)+'\x00'); out.push(''); bqStart=-1; }; for(let i=0;i/.test(line)){ if(bqStart<0) bqStart=i; } else { flush(i); out.push(line); } } flush(lines.length); return out.join('\n'); })(s); // ── 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 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. // Split into fenced blocks (\x00P — kept stashed until after all markdown passes) // and inline backtick spans (\x00F — restored before bold/italic so **`code`** works). // Fenced blocks are converted to

 here so their content is HTML-escaped
  // and never exposed to list/heading/table regexes that could corrupt the layout.
  // Fixes #1154: diff/patch lines inside fenced blocks (e.g. + added, - removed)
  // were matching the unordered-list regex and injecting