mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-19 13:47:04 +00:00
8071 lines
362 KiB
JavaScript
8071 lines
362 KiB
JavaScript
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false};
|
||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||
const MAX_UPLOAD_BYTES=(window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.maxUploadBytes)||20*1024*1024;
|
||
const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
|
||
// 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);
|
||
const OFFLINE_RECHECK_MS=2500;
|
||
let _offlineVisible=false;
|
||
let _offlineReason='browser';
|
||
let _offlineProbeTimer=null;
|
||
let _offlineChecking=false;
|
||
let _offlineProbePromise=null;
|
||
let _offlineHealthProbePromise=null;
|
||
let _offlineRawFetch=null;
|
||
let _offlineFetchPatched=false;
|
||
function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
|
||
function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
|
||
function _setOfflineChecking(checking){
|
||
_offlineChecking=!!checking;
|
||
const btn=$('offlineCheckNow');
|
||
if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
|
||
}
|
||
function _renderOfflineBanner(){
|
||
const banner=$('offlineBanner');
|
||
if(!banner)return;
|
||
const detail=$('offlineDetails');
|
||
if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
|
||
const title=$('offlineTitle');
|
||
if(title)title.textContent=t('offline_title');
|
||
const auto=$('offlineAutorefresh');
|
||
if(auto)auto.textContent=t('offline_autorefresh');
|
||
_setOfflineChecking(_offlineChecking);
|
||
banner.hidden=false;
|
||
banner.classList.add('visible');
|
||
}
|
||
function _startOfflineProbeTimer(){
|
||
if(_offlineProbeTimer)return;
|
||
_offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
|
||
}
|
||
function _stopOfflineProbeTimer(){
|
||
if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
|
||
}
|
||
function showOfflineBanner(reason){
|
||
_offlineVisible=true;
|
||
_offlineReason=reason||(_browserReportsOnline()?'network':'browser');
|
||
_renderOfflineBanner();
|
||
_startOfflineProbeTimer();
|
||
}
|
||
function isOfflineBannerVisible(){return _offlineVisible;}
|
||
function _hideOfflineBanner(){
|
||
_offlineVisible=false;
|
||
_stopOfflineProbeTimer();
|
||
_setOfflineChecking(false);
|
||
const banner=$('offlineBanner');
|
||
if(banner){banner.classList.remove('visible');banner.hidden=true;}
|
||
}
|
||
async function _probeOfflineRecovery(){
|
||
if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
|
||
_offlineHealthProbePromise=(async()=>{
|
||
const fetcher=_offlineRawFetch||window.fetch.bind(window);
|
||
try{
|
||
const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'});
|
||
return !!(res&&res.ok);
|
||
}catch(_){return false;}
|
||
})();
|
||
try{return await _offlineHealthProbePromise;}
|
||
finally{_offlineHealthProbePromise=null;}
|
||
}
|
||
async function checkOfflineRecoveryNow(){
|
||
if(_offlineProbePromise)return _offlineProbePromise;
|
||
_offlineProbePromise=(async()=>{
|
||
if(!_offlineVisible)return false;
|
||
if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
|
||
_setOfflineChecking(true);
|
||
const ok=await _probeOfflineRecovery();
|
||
_setOfflineChecking(false);
|
||
if(ok){_stopOfflineProbeTimer();window.location.reload();return true;}
|
||
showOfflineBanner('network');
|
||
return false;
|
||
})();
|
||
try{return await _offlineProbePromise;}
|
||
finally{_offlineProbePromise=null;}
|
||
}
|
||
function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
|
||
function _patchOfflineFetch(){
|
||
if(_offlineFetchPatched||typeof window.fetch!=='function')return;
|
||
_offlineFetchPatched=true;
|
||
_offlineRawFetch=window.fetch.bind(window);
|
||
window.fetch=async function(...args){
|
||
try{return await _offlineRawFetch(...args);}
|
||
catch(e){
|
||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||
else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
|
||
throw e;
|
||
}
|
||
};
|
||
}
|
||
function initOfflineMonitor(){
|
||
_patchOfflineFetch();
|
||
window.addEventListener('offline',()=>showOfflineBanner('browser'));
|
||
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
|
||
if(!_browserReportsOnline())showOfflineBanner('browser');
|
||
}
|
||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
|
||
else initOfflineMonitor();
|
||
// Redirect to login when the server responds with 401 (auth session expired).
|
||
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
|
||
// escaping to the personal site root /login.
|
||
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]));
|
||
function _matchBacktickFenceLine(line){
|
||
const m=String(line||'').match(/^[ ]{0,3}(`{3,})([^`]*)$/);
|
||
if(!m) return null;
|
||
return {fence:m[1],len:m[1].length,info:(m[2]||'').trim()};
|
||
}
|
||
function _isBacktickFenceClose(line,minLen){
|
||
const m=String(line||'').match(/^[ ]{0,3}(`{3,})[ \t]*$/);
|
||
return !!(m&&m[1].length>=minLen);
|
||
}
|
||
/**
|
||
* Render fenced code blocks inside user messages.
|
||
* Extracts ```…``` fences, replaces them with placeholders,
|
||
* escapes remaining text as plain HTML, then restores code blocks
|
||
* with the same <pre><code> pipeline used by renderMd().
|
||
* All non-fenced text stays escaped (no bold/italic/link interpretation).
|
||
*/
|
||
|
||
function _stripWorkspaceDisplayPrefix(text){
|
||
// v1 sentinel format `[Workspace::v1: <escaped path>]\n` injected since #1918.
|
||
// Legacy format `[Workspace: <path>]\n` may still be present in transcripts
|
||
// saved before the v1 migration; fall through to the legacy regex when the
|
||
// v1 strip didn't match. Mirrors the Python `include_legacy=True` branch in
|
||
// api/streaming.py:_strip_workspace_prefix(). Per Opus advisor on stage-322.
|
||
const value = String(text||'');
|
||
const stripped = value.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
|
||
if(stripped !== value) return stripped.trim();
|
||
return value.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
|
||
}
|
||
function _renderUserFencedBlocks(text){
|
||
const stash=[];
|
||
const mathStash=[];
|
||
const stashMath=(type,src)=>{mathStash.push({type,src});return '\x00UM'+(mathStash.length-1)+'\x00';};
|
||
const restoreMath=html=>String(html||'').replace(/\x00UM(\d+)\x00/g,(_,i)=>{
|
||
const item=mathStash[+i];
|
||
if(!item) return '';
|
||
if(item.type==='display') return `<div class="katex-block" data-katex="display">${esc(item.src)}</div>`;
|
||
return `<span class="katex-inline" data-katex="inline">${esc(item.src)}</span>`;
|
||
});
|
||
let s=String(text||'');
|
||
// Extract fenced code blocks FIRST so math regexes never run inside fenced
|
||
// content. If math were stashed first, a user-typed code block containing
|
||
// \[..\] / \(..\) / $$..$$ would be rendered as a KaTeX block inside
|
||
// <pre><code> instead of as literal source. Mirrors renderMd()'s ordering.
|
||
// CommonMark §4.5 line-anchored fence: the closing run must use at least
|
||
// as many backticks as the opener, so inner triple-backtick fences remain content.
|
||
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
|
||
const langInfo=(info||'').trim();
|
||
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
|
||
let lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
|
||
code=code||'';
|
||
// Remove one trailing newline if present (the fence consumes its own)
|
||
if(code.endsWith('\n')) code=code.slice(0,-1);
|
||
const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';
|
||
const langAttr=lang?` class="language-${esc(lang)}"`:'';
|
||
if(lang==='diff'||lang==='patch'){
|
||
const colored=esc(code).split('\n').map(line=>{
|
||
if(line.startsWith('@@')) return `<span class="diff-line diff-hunk">${line}</span>`;
|
||
if(line.startsWith('+')) return `<span class="diff-line diff-plus">${line}</span>`;
|
||
if(line.startsWith('-')) return `<span class="diff-line diff-minus">${line}</span>`;
|
||
return `<span class="diff-line">${line}</span>`;
|
||
}).join('\n');
|
||
stash.push(`${h}<pre class="diff-block"><code${langAttr}>${colored}</code></pre>`);
|
||
} else {
|
||
stash.push(`${h}<pre><code${langAttr}>${esc(code)}</code></pre>`);
|
||
}
|
||
return lead+'\x00UF'+(stash.length-1)+'\x00';
|
||
});
|
||
// Now stash math from the OUTSIDE-of-fence text. Display delimiters must
|
||
// run before inline so $$..$$ isn't mis-parsed as $..$..$..$.
|
||
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>stashMath('display',m));
|
||
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
|
||
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
|
||
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
|
||
// Escape remaining plain text and convert newlines to <br>
|
||
s=esc(s).replace(/\n/g,'<br>');
|
||
// Restore stashed code blocks, then math placeholders as KaTeX targets.
|
||
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
|
||
s=restoreMath(s);
|
||
return s;
|
||
}
|
||
function _statusCardHtml(card){
|
||
card=card||{};
|
||
const rows=Array.isArray(card.rows)?card.rows:[];
|
||
const sessionId=String(card.sessionId||'');
|
||
const shortSessionId=sessionId.length>22?`${sessionId.slice(0,10)}…${sessionId.slice(-8)}`:sessionId;
|
||
const copyIcon=(typeof li==='function')?li('copy',13):'Copy';
|
||
const copyBtn=sessionId
|
||
? `<button class="status-card-session-copy" type="button" data-copy-status-session="${esc(card.sessionId||'')}" title="${esc(t('copy'))}" onclick="copyStatusSessionId(this);event.stopPropagation()"><span>${esc(shortSessionId)}</span>${copyIcon}</button>`
|
||
: '';
|
||
const rowHtml=rows.map(row=>`
|
||
<div class="status-card-row">
|
||
<span class="status-card-label">${esc(row.label||'')}</span>
|
||
<span class="status-card-value">${esc(row.value||'')}</span>
|
||
</div>`).join('');
|
||
return `<div class="status-card" data-status-card="1">
|
||
<div class="status-card-head">
|
||
<div class="status-card-title-wrap">
|
||
<div class="status-card-title">${esc(card.title||t('status_heading'))}</div>
|
||
<div class="status-card-subtitle">${esc(card.subtitle||'')}</div>
|
||
</div>
|
||
${copyBtn}
|
||
</div>
|
||
<div class="status-card-grid">${rowHtml}</div>
|
||
</div>`;
|
||
}
|
||
|
||
const MESSAGE_RENDER_WINDOW_DEFAULT=50;
|
||
let _messageRenderWindowSid=null;
|
||
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
||
function _resetMessageRenderWindow(sid){
|
||
_messageRenderWindowSid=sid||null;
|
||
_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
|
||
}
|
||
function _currentMessageRenderWindowSize(){
|
||
return Math.max(
|
||
MESSAGE_RENDER_WINDOW_DEFAULT,
|
||
Number(_messageRenderWindowSize)||MESSAGE_RENDER_WINDOW_DEFAULT
|
||
);
|
||
}
|
||
function _messageRenderableMessageCount(){
|
||
let count=0;
|
||
for(const m of (S.messages||[])){
|
||
if(!m||!m.role||m.role==='tool') continue;
|
||
if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) continue;
|
||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) count++;
|
||
}
|
||
return count;
|
||
}
|
||
function _messageHiddenBeforeCount(){
|
||
return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize());
|
||
}
|
||
function _isSessionEndlessScrollEnabled(){
|
||
return window._sessionEndlessScrollEnabled===true;
|
||
}
|
||
function _wireMessageWindowLoadEarlierButton(){
|
||
const indicator=$('loadOlderIndicator');
|
||
if(!indicator) return;
|
||
indicator.onclick=()=>{
|
||
if(_messageHiddenBeforeCount()>0) _showEarlierRenderedMessages();
|
||
else if(typeof _loadOlderMessages==='function') _loadOlderMessages();
|
||
};
|
||
}
|
||
function _showEarlierRenderedMessages(){
|
||
const container=$('messages');
|
||
const prevScrollH=container?container.scrollHeight:0;
|
||
const prevScrollTop=container?container.scrollTop:0;
|
||
_messageRenderWindowSize=_currentMessageRenderWindowSize()+MESSAGE_RENDER_WINDOW_DEFAULT;
|
||
renderMessages();
|
||
if(container){
|
||
const newScrollH=container.scrollHeight;
|
||
container.scrollTop=prevScrollTop+(newScrollH-prevScrollH);
|
||
}
|
||
_scrollPinned=false;
|
||
}
|
||
function _isSessionJumpButtonsEnabled(){
|
||
return window._sessionJumpButtonsEnabled===true;
|
||
}
|
||
function _applySessionNavigationPrefs(){
|
||
const container=$('messages');
|
||
if(container) container.classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled());
|
||
_updateSessionStartJumpButton();
|
||
}
|
||
function _updateSessionStartJumpButton(){
|
||
const btn=$('jumpToSessionStartBtn');
|
||
const container=$('messages');
|
||
if(!btn||!container) return;
|
||
if(!_isSessionJumpButtonsEnabled()){
|
||
btn.style.display='none';
|
||
return;
|
||
}
|
||
const hasSession=!!(S&&S.session&&S.messages&&S.messages.length);
|
||
const awayFromStart=container.scrollTop>Math.max(240,container.clientHeight*0.35);
|
||
const hasScrollableHistory=container.scrollHeight>container.clientHeight+Math.max(240,container.clientHeight*0.35);
|
||
const canRevealStart=hasScrollableHistory||_messageHiddenBeforeCount()>0||!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated);
|
||
btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none';
|
||
}
|
||
async function jumpToSessionStart(){
|
||
const container=$('messages');
|
||
if(!container||!S.session) return;
|
||
_scrollPinned=false;
|
||
_messageUserUnpinned=true;
|
||
_programmaticScroll=true;
|
||
try{
|
||
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
|
||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
|
||
renderMessages({ preserveScroll:true });
|
||
requestAnimationFrame(()=>{
|
||
container.scrollTop=0;
|
||
_updateSessionStartJumpButton();
|
||
requestAnimationFrame(()=>{ _programmaticScroll=false; });
|
||
});
|
||
}catch(e){
|
||
console.warn('jumpToSessionStart failed:',e);
|
||
_programmaticScroll=false;
|
||
}
|
||
}
|
||
|
||
function _userMessageDomId(rawIdx){
|
||
return `msg-user-${rawIdx}`;
|
||
}
|
||
|
||
function _questionJumpButtonHtml(questionRawIdx){
|
||
if(typeof questionRawIdx!=='number'||questionRawIdx<0) return '';
|
||
const label=t('jump_to_question')||'Question';
|
||
const title=t('jump_to_question_label')||'Jump to the question for this response';
|
||
return `<button class="msg-question-jump-btn" type="button" title="${esc(title)}" aria-label="${esc(title)}" onclick="jumpToTurnQuestion(${questionRawIdx})"><span aria-hidden="true">↑</span><span>${esc(label)}</span></button>`;
|
||
}
|
||
|
||
function _highlightQuestionRow(row){
|
||
if(!row) return;
|
||
row.classList.remove('msg-question-highlight');
|
||
void row.offsetWidth;
|
||
row.classList.add('msg-question-highlight');
|
||
window.setTimeout(()=>row.classList.remove('msg-question-highlight'),1800);
|
||
}
|
||
|
||
async function jumpToTurnQuestion(questionRawIdx){
|
||
const container=$('messages');
|
||
if(!container||typeof questionRawIdx!=='number'||questionRawIdx<0) return;
|
||
const scrollToTarget=()=>{
|
||
const row=document.getElementById(_userMessageDomId(questionRawIdx));
|
||
if(!row) return false;
|
||
row.scrollIntoView({block:'center',behavior:'smooth'});
|
||
_highlightQuestionRow(row);
|
||
return true;
|
||
};
|
||
if(scrollToTarget()) return;
|
||
if(_messageHiddenBeforeCount()>0){
|
||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
|
||
renderMessages({ preserveScroll:true });
|
||
requestAnimationFrame(scrollToTarget);
|
||
}
|
||
}
|
||
|
||
const DASHBOARD_STATUS_TTL_MS=60000;
|
||
let _dashboardStatusCache=null;
|
||
let _dashboardStatusFetchedAt=0;
|
||
|
||
function _dashboardIsBrowserLoopback(){
|
||
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
|
||
return host==='127.0.0.1'||host==='localhost'||host==='::1';
|
||
}
|
||
function _dashboardBrowserUrl(status){
|
||
if(!status||!status.running||!status.port) return '';
|
||
let source;
|
||
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
|
||
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
|
||
const browserHost=window.location.hostname||source.hostname;
|
||
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
|
||
return source.protocol+'//'+displayHost+':'+status.port;
|
||
}
|
||
function _applyDashboardStatus(status){
|
||
const running=!!(status&&status.running);
|
||
const url=running?_dashboardBrowserUrl(status):'';
|
||
const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
|
||
document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{
|
||
btn.classList.toggle('dashboard-link-visible',running);
|
||
btn.style.display=running?'':'none';
|
||
btn.dataset.dashboardUrl=url;
|
||
const tipText=warning||t('tab_dashboard');
|
||
if(btn.hasAttribute('data-tooltip')){
|
||
// Sync the custom CSS tooltip and explicitly clear the native title so
|
||
// the slow ~1.5s native browser tooltip does not co-fire alongside the
|
||
// fast custom tooltip (#1775).
|
||
btn.setAttribute('data-tooltip',tipText);
|
||
if(btn.hasAttribute('title')) btn.removeAttribute('title');
|
||
} else {
|
||
btn.title=tipText;
|
||
}
|
||
btn.setAttribute('aria-label',tipText);
|
||
});
|
||
}
|
||
async function refreshDashboardStatus(force=false){
|
||
const now=Date.now();
|
||
if(!force&&_dashboardStatusCache&&(now-_dashboardStatusFetchedAt)<DASHBOARD_STATUS_TTL_MS){
|
||
_applyDashboardStatus(_dashboardStatusCache);
|
||
return _dashboardStatusCache;
|
||
}
|
||
try{
|
||
const status=await api('/api/dashboard/status');
|
||
_dashboardStatusCache=status||{running:false};
|
||
}catch(_){
|
||
_dashboardStatusCache={running:false};
|
||
}
|
||
_dashboardStatusFetchedAt=Date.now();
|
||
_applyDashboardStatus(_dashboardStatusCache);
|
||
return _dashboardStatusCache;
|
||
}
|
||
async function loadDashboardSettings(){
|
||
const modeEl=$('settingsDashboardMode');
|
||
const urlEl=$('settingsDashboardUrl');
|
||
if(!modeEl&&!urlEl) return;
|
||
try{
|
||
const cfg=await api('/api/dashboard/config');
|
||
if(modeEl) modeEl.value=cfg.enabled||'auto';
|
||
if(urlEl) urlEl.value=cfg.url||'';
|
||
}catch(_){/* leave defaults visible */}
|
||
}
|
||
async function saveDashboardSettings(){
|
||
const modeEl=$('settingsDashboardMode');
|
||
const urlEl=$('settingsDashboardUrl');
|
||
const statusEl=$('settingsDashboardStatus');
|
||
const payload={enabled:(modeEl&&modeEl.value)||'auto',url:(urlEl&&urlEl.value||'').trim()};
|
||
try{
|
||
const saved=await api('/api/dashboard/config',{method:'POST',body:JSON.stringify(payload)});
|
||
if(modeEl) modeEl.value=saved.enabled||'auto';
|
||
if(urlEl) urlEl.value=saved.url||'';
|
||
if(statusEl) statusEl.textContent='Dashboard link settings saved.';
|
||
await refreshDashboardStatus(true);
|
||
}catch(err){
|
||
if(statusEl) statusEl.textContent='Dashboard link settings failed to save.';
|
||
else if(typeof showToast==='function') showToast('Dashboard link settings failed to save.');
|
||
}
|
||
}
|
||
function openHermesDashboard(event){
|
||
if(event){event.preventDefault();event.stopPropagation();}
|
||
const btn=event&&event.currentTarget?event.currentTarget:document.querySelector('[data-dashboard-link]');
|
||
const url=(btn&&btn.dataset&&btn.dataset.dashboardUrl)||_dashboardBrowserUrl(_dashboardStatusCache);
|
||
if(!url) return false;
|
||
window.open(url,'_blank','noopener,noreferrer');
|
||
return false;
|
||
}
|
||
function _initDashboardLinkProbe(){
|
||
loadDashboardSettings();
|
||
refreshDashboardStatus(true);
|
||
setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS);
|
||
}
|
||
if(document.readyState==='complete'){
|
||
_initDashboardLinkProbe();
|
||
}else{
|
||
document.addEventListener('DOMContentLoaded',_initDashboardLinkProbe,{once:true});
|
||
}
|
||
|
||
/* ── 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 => {
|
||
if(!e.target || !e.target.closest) return;
|
||
// Message-attached images (already wired since v0.50.x).
|
||
let img = e.target.closest('.msg-media-img');
|
||
if(img){ _openImgLightbox(img.src, img.alt); return; }
|
||
// Composer attach-tray image thumbnails — click any pasted/dropped image
|
||
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
|
||
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
|
||
// are still images visually, so they qualify.
|
||
img = e.target.closest('.attach-thumb');
|
||
if(img && img.tagName === 'IMG'){
|
||
_openImgLightbox(img.src, img.alt || img.title || 'Attached image');
|
||
return;
|
||
}
|
||
});
|
||
|
||
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 `<div class="media-speed-controls" role="group" aria-label="Playback speed for ${safeLabel}">${MEDIA_PLAYBACK_RATES.map(rate=>`<button type="button" class="media-speed-btn${rate===current?' active':''}" data-rate="${rate}" aria-pressed="${rate===current?'true':'false'}">${rate}×</button>`).join('')}</div>`;
|
||
}
|
||
function _mediaPlayerHtml(kind, src, name, extra=''){
|
||
const safeName=esc(name||'media');
|
||
const safeSrc=esc(src);
|
||
const tag=kind==='video'
|
||
? `<video class="msg-media-player msg-media-video" src="${safeSrc}" controls preload="metadata" playsinline title="${safeName}"></video>`
|
||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span>${extra}</div>${_mediaSpeedControlsHtml(kind,safeName)}</div>`;
|
||
}
|
||
function _renderAttachmentHtml(fname, url){
|
||
const kind=_mediaKindForName(fname);
|
||
if(kind==='image') return `<img class="msg-media-img" src="${esc(url)}" alt="${esc(fname)}" loading="lazy">`;
|
||
if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
|
||
if(_HTML_EXTS.test(fname)){
|
||
const inlineUrl=url+(String(url).includes('?')?'&':'?')+'inline=1';
|
||
return `<a class="msg-file-badge msg-file-badge--html" href="${esc(inlineUrl)}" target="_blank" rel="noopener">${li('file-code',12)} ${esc(fname)}</a>`;
|
||
}
|
||
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
|
||
}
|
||
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);
|
||
|
||
// ── Ambient provider quota indicator (#1766) ────────────────────────────────
|
||
let _providerQuotaRefreshInFlight=false;
|
||
|
||
function _formatQuotaMoneyShort(value){
|
||
const n=Number(value);
|
||
if(!Number.isFinite(n)) return '';
|
||
if(Math.abs(n)>=100) return '$'+n.toFixed(0);
|
||
if(Math.abs(n)>=10) return '$'+n.toFixed(1);
|
||
return '$'+n.toFixed(2);
|
||
}
|
||
function _formatQuotaPercentShort(value){
|
||
const n=Number(value);
|
||
if(!Number.isFinite(n)) return '';
|
||
return Math.max(0,Math.min(100,n)).toFixed(0)+'%';
|
||
}
|
||
function _providerQuotaIndicatorText(status){
|
||
if(!status||status.status!=='available') return null;
|
||
const provider=status.display_name||status.provider||'Provider';
|
||
const accountLimits=status.account_limits||null;
|
||
if(accountLimits&&Array.isArray(accountLimits.windows)&&accountLimits.windows.length){
|
||
const w=accountLimits.windows.find(x=>x&&Number.isFinite(Number(x.remaining_percent)))||accountLimits.windows[0];
|
||
const remaining=_formatQuotaPercentShort(w&&w.remaining_percent);
|
||
if(remaining) return {label:provider+' '+remaining, title:(status.message||'Provider usage loaded')+' — '+remaining+' remaining'};
|
||
}
|
||
const quota=status.quota||null;
|
||
if(quota){
|
||
const remaining=_formatQuotaMoneyShort(quota.limit_remaining);
|
||
const used=_formatQuotaMoneyShort(quota.usage);
|
||
const limit=_formatQuotaMoneyShort(quota.limit);
|
||
if(remaining){
|
||
const parts=[];
|
||
if(used) parts.push('used '+used);
|
||
if(limit) parts.push('limit '+limit);
|
||
return {label:provider+' '+remaining, title:(status.message||'Provider quota loaded')+(parts.length?' — '+parts.join(' · '):'')};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function renderProviderQuotaIndicator(status){
|
||
const chip=$('providerQuotaChip');
|
||
const label=$('providerQuotaChipLabel');
|
||
if(!chip||!label) return;
|
||
// Hide entirely when the user has disabled the ambient quota chip in Settings.
|
||
// Default is off (window._showQuotaChip defaults to false in boot.js) so users
|
||
// never see the chip unless they opt in.
|
||
if(window._showQuotaChip!==true){
|
||
chip.hidden=true;
|
||
label.textContent='';
|
||
chip.removeAttribute('title');
|
||
return;
|
||
}
|
||
const text=_providerQuotaIndicatorText(status);
|
||
if(!text||status.status!=='available'||(!status.quota&&!status.account_limits)){
|
||
chip.hidden=true;
|
||
label.textContent='';
|
||
chip.removeAttribute('title');
|
||
return;
|
||
}
|
||
label.textContent=text.label;
|
||
chip.title=text.title;
|
||
chip.hidden=false;
|
||
}
|
||
async function refreshProviderQuotaIndicator(){
|
||
// Short-circuit before the fetch when the chip is disabled — no point asking
|
||
// the server for quota data the UI will throw away.
|
||
if(window._showQuotaChip!==true){
|
||
const chip=$('providerQuotaChip');
|
||
if(chip){chip.hidden=true;chip.removeAttribute('title');}
|
||
return;
|
||
}
|
||
if(_providerQuotaRefreshInFlight) return;
|
||
_providerQuotaRefreshInFlight=true;
|
||
try{
|
||
const status=await api('/api/provider/quota');
|
||
renderProviderQuotaIndicator(status);
|
||
}catch(_e){
|
||
renderProviderQuotaIndicator(null);
|
||
}finally{
|
||
_providerQuotaRefreshInFlight=false;
|
||
}
|
||
}
|
||
window.addEventListener('visibilitychange',()=>{
|
||
if(document.visibilityState==='visible'&&typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||
});
|
||
|
||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||
let _dynamicModelLabels={};
|
||
window._configuredModelBadges=window._configuredModelBadges||{};
|
||
const MODEL_STATE_KEY='hermes-webui-model-state';
|
||
|
||
// ── Smart model resolver ────────────────────────────────────────────────────
|
||
// Finds the best matching option value in a <select> for a given model ID.
|
||
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
|
||
// When a preferred provider is supplied, duplicate normalized IDs prefer that
|
||
// provider's option so Settings/profile rehydration doesn't snap back to the
|
||
// first colliding entry.
|
||
function _getOptionProviderId(opt){
|
||
if(!opt) return '';
|
||
const group=opt.parentElement;
|
||
if(group && group.tagName==='OPTGROUP' && group.dataset && group.dataset.provider){
|
||
return group.dataset.provider;
|
||
}
|
||
const value=String(opt.value||'');
|
||
if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||
return '';
|
||
}
|
||
function _providerFromModelValue(modelId){
|
||
const value=String(modelId||'').trim();
|
||
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
|
||
return '';
|
||
}
|
||
function _providerSkipsModelMismatchWarning(providerId){
|
||
const p=String(providerId||'').toLowerCase();
|
||
return !p||p==='custom'||p.startsWith('custom:')||p==='openrouter';
|
||
}
|
||
function _providerDefersMissingModelFallback(providerId){
|
||
const p=String(providerId||'').toLowerCase();
|
||
// Named custom providers and OpenRouter can legitimately route vendor-prefixed
|
||
// model IDs that are not present in the current static catalog. Do not
|
||
// silently rewrite those sessions to the default just because the option has
|
||
// not been hydrated yet (#2405).
|
||
return p.startsWith('custom:')||p==='openrouter';
|
||
}
|
||
function _modelStateForSelect(sel, modelId){
|
||
const value=String(modelId||'').trim();
|
||
if(!value) return {model:'',model_provider:null};
|
||
const explicitProvider=_providerFromModelValue(value);
|
||
if(explicitProvider) return {model:value,model_provider:explicitProvider};
|
||
const opt=sel&&sel.selectedOptions&&sel.selectedOptions[0];
|
||
const provider=String(_getOptionProviderId(opt)||'').trim();
|
||
return {model:value,model_provider:(provider&&provider!=='default')?provider:null};
|
||
}
|
||
function _providerQualifiedModelValueForSelect(sel, modelId){
|
||
return _modelStateForSelect(sel,modelId).model;
|
||
}
|
||
function _readPersistedModelState(){
|
||
try{
|
||
const raw=localStorage.getItem(MODEL_STATE_KEY);
|
||
if(raw){
|
||
const parsed=JSON.parse(raw);
|
||
if(parsed&&parsed.model){
|
||
return {
|
||
model:String(parsed.model||''),
|
||
model_provider:parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(parsed.model)||null),
|
||
};
|
||
}
|
||
}
|
||
}catch(_){}
|
||
const legacy=localStorage.getItem('hermes-webui-model');
|
||
if(!legacy) return null;
|
||
return {model:legacy,model_provider:_providerFromModelValue(legacy)||null};
|
||
}
|
||
function _writePersistedModelState(model, modelProvider){
|
||
const value=String(model||'').trim();
|
||
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
|
||
if(!value){
|
||
localStorage.removeItem('hermes-webui-model');
|
||
localStorage.removeItem(MODEL_STATE_KEY);
|
||
return;
|
||
}
|
||
localStorage.setItem('hermes-webui-model', value);
|
||
try{
|
||
localStorage.setItem(MODEL_STATE_KEY, JSON.stringify({model:value,model_provider:provider||null}));
|
||
}catch(_){}
|
||
}
|
||
function _clearPersistedModelState(){
|
||
localStorage.removeItem('hermes-webui-model');
|
||
localStorage.removeItem(MODEL_STATE_KEY);
|
||
}
|
||
function _findModelInDropdown(modelId, sel, preferredProviderId){
|
||
if(!modelId||!sel) return null;
|
||
const options=Array.from(sel.options);
|
||
const opts=options.map(o=>o.value);
|
||
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
|
||
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
|
||
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
|
||
const target=norm(modelId);
|
||
let explicitProvider='';
|
||
const rawModel=String(modelId||'');
|
||
if(rawModel.startsWith('@')&&rawModel.includes(':')){
|
||
explicitProvider=rawModel.slice(1,rawModel.lastIndexOf(':'));
|
||
}
|
||
const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
|
||
if(preferred){
|
||
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
|
||
if(providerMatch) return providerMatch.value;
|
||
}
|
||
// 2. Exact match
|
||
if(opts.includes(modelId)) return modelId;
|
||
const exact=opts.find(o=>norm(o)===target);
|
||
if(exact) return exact;
|
||
// 3. Prefix/substring: require the candidate to start with the FULL normalized target
|
||
// (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188).
|
||
// Only fall back to the shorter base form if target itself is very short (a bare root
|
||
// like "gpt" or "claude") where stripping would be a no-op anyway.
|
||
const base=target.replace(/\.\d+$/,''); // strip trailing version number
|
||
const useBase=base.length<=4||base===target; // bare root — stripping changed nothing meaningful
|
||
const prefixTarget=useBase?base:target;
|
||
const partial=opts.find(o=>norm(o).startsWith(prefixTarget));
|
||
return partial||null;
|
||
}
|
||
|
||
// Set the model picker to the best match for modelId.
|
||
// Returns the resolved value that was actually set, or null if nothing matched.
|
||
function _refreshOpenModelDropdown(){
|
||
const dd=$('composerModelDropdown');
|
||
if(dd&&dd.classList&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
|
||
renderModelDropdown();
|
||
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
|
||
}
|
||
}
|
||
function _applyModelToDropdown(modelId, sel, preferredProviderId){
|
||
if(!modelId||!sel) return null;
|
||
const resolved=_findModelInDropdown(modelId,sel,preferredProviderId);
|
||
if(resolved){
|
||
sel.value=resolved;
|
||
if(sel.id==='modelSelect'){
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
_refreshOpenModelDropdown();
|
||
}
|
||
return resolved;
|
||
}
|
||
return null;
|
||
}
|
||
function _modelStateFromAppliedDropdown(sel, modelValue){
|
||
const state=(typeof _modelStateForSelect==='function')
|
||
? _modelStateForSelect(sel,modelValue)
|
||
: {model:modelValue,model_provider:null};
|
||
return {model:state.model||modelValue,model_provider:state.model_provider||null};
|
||
}
|
||
function _persistSessionModelCorrection(model, provider){
|
||
if(!S.session) return;
|
||
fetch(new URL('api/session/update',document.baseURI||location.href).href,{
|
||
method:'POST',credentials:'include',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
|
||
}).catch(()=>{});
|
||
}
|
||
function _applySessionModelFallback(sel){
|
||
if(!sel) return null;
|
||
const configuredDefault=String(window._defaultModel||'').trim();
|
||
if(configuredDefault){
|
||
const appliedDefault=_applyModelToDropdown(configuredDefault,sel,window._activeProvider||null);
|
||
if(appliedDefault) return _modelStateFromAppliedDropdown(sel,appliedDefault);
|
||
}
|
||
const first=sel.querySelector('optgroup > option, option');
|
||
if(first){
|
||
sel.value=first.value;
|
||
if(sel.id==='modelSelect'){
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
_refreshOpenModelDropdown();
|
||
}
|
||
return _modelStateFromAppliedDropdown(sel,first.value);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function populateModelDropdown(){
|
||
const sel=$('modelSelect');
|
||
if(!sel) return;
|
||
try{
|
||
const _modelsRes=await fetch(new URL('api/models',document.baseURI||location.href).href,{credentials:'include'});
|
||
if(_redirectIfUnauth(_modelsRes)) return;
|
||
const data=await _modelsRes.json();
|
||
// Store active provider globally so the send path can warn on mismatch
|
||
window._activeProvider=data.active_provider||null;
|
||
// Store default model so newSession() can apply it (#872).
|
||
// Per-page-load — not synced across browser tabs.
|
||
window._defaultModel=data.default_model||null;
|
||
window._configuredModelBadges=data.configured_model_badges||{};
|
||
|
||
const _synthGroupsFromConfigured=()=>{
|
||
const badgeMap=window._configuredModelBadges||{};
|
||
const grouped=new Map();
|
||
const addModel=(providerId,modelId)=>{
|
||
const pid=String(providerId||'configured').trim()||'configured';
|
||
const mid=String(modelId||'').trim();
|
||
if(!mid) return;
|
||
if(!grouped.has(pid)) grouped.set(pid,[]);
|
||
const arr=grouped.get(pid);
|
||
if(arr.some(m=>m.id===mid)) return;
|
||
arr.push({id:mid,label:getModelLabel(mid)});
|
||
};
|
||
|
||
for(const [modelId,badge] of Object.entries(badgeMap)){
|
||
const mid=String(modelId||'').trim();
|
||
// Prefer canonical IDs only; skip derived aliases such as
|
||
// @provider:model and provider/model to avoid noisy duplicates.
|
||
if(!mid||mid.startsWith('@')||mid.includes('/')) continue;
|
||
const provider=(badge&&badge.provider)||'configured';
|
||
addModel(provider,mid);
|
||
}
|
||
|
||
if(grouped.size===0&&data&&data.default_model){
|
||
addModel(data.active_provider||'configured',data.default_model);
|
||
}
|
||
|
||
const groups=[];
|
||
for(const [providerId,models] of grouped.entries()){
|
||
const display=(String(providerId).startsWith('custom:')
|
||
? String(providerId).slice('custom:'.length)
|
||
: String(providerId))||'Configured';
|
||
groups.push({provider:display,provider_id:providerId,models});
|
||
}
|
||
return groups;
|
||
};
|
||
|
||
const groups=(Array.isArray(data.groups)&&data.groups.length)
|
||
? data.groups
|
||
: _synthGroupsFromConfigured();
|
||
|
||
if(!groups.length) return; // no server groups and no configured fallback
|
||
// Clear existing options
|
||
sel.innerHTML='';
|
||
_dynamicModelLabels={};
|
||
for(const g of groups){
|
||
const og=document.createElement('optgroup');
|
||
og.label=g.provider;
|
||
if(g.provider_id) og.dataset.provider=g.provider_id;
|
||
for(const m of (Array.isArray(g.models)?g.models:[])){
|
||
const opt=document.createElement('option');
|
||
opt.value=m.id;
|
||
opt.textContent=m.label;
|
||
og.appendChild(opt);
|
||
_dynamicModelLabels[m.id]=m.id;
|
||
}
|
||
// Hydrate the label map from extra_models too (the catalog tail that
|
||
// doesn't render as <option> entries when the picker is capped — see
|
||
// _build_nous_featured_set in api/config.py for the rationale). This
|
||
// keeps a model selected from the slash-command autocomplete or a
|
||
// persisted-localStorage value renderable with its proper label
|
||
// instead of falling back to the bare ID. #1567.
|
||
if(Array.isArray(g.extra_models)){
|
||
for(const m of g.extra_models){
|
||
if(m && m.id) _dynamicModelLabels[m.id]=m.id;
|
||
}
|
||
}
|
||
sel.appendChild(og);
|
||
}
|
||
// Set default model from server if no localStorage preference
|
||
if(data.default_model && !(typeof _readPersistedModelState==='function'&&_readPersistedModelState()) && !localStorage.getItem('hermes-webui-model')){
|
||
_applyModelToDropdown(data.default_model, sel, data.active_provider||null);
|
||
}
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
const dd=$('composerModelDropdown');
|
||
if(dd&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
|
||
renderModelDropdown();
|
||
_positionModelDropdown();
|
||
}
|
||
// Kick off a background live-model fetch for the active provider.
|
||
// This runs after the static list is already shown (no blocking flicker).
|
||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
||
}catch(e){
|
||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||
console.warn('Failed to load models from server:',e.message);
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
}
|
||
}
|
||
|
||
// Cache so we don't re-fetch on every page load
|
||
const _liveModelCache={};
|
||
// Tracks providers for which a live-model fetch is in flight.
|
||
// Used by syncTopbar() to defer model corrections until the fetch completes,
|
||
// preventing premature fallback to the first static model (#1169).
|
||
const _liveModelFetchPending=new Set();
|
||
|
||
function _addLiveModelsToSelect(provider, models, sel){
|
||
if(!provider||!models||!models.length||!sel) return 0;
|
||
const currentVal=sel.value;
|
||
let providerGroup=null;
|
||
for(const og of sel.querySelectorAll('optgroup')){
|
||
if(og.dataset.provider&&og.dataset.provider===provider){
|
||
providerGroup=og; break;
|
||
}
|
||
if(og.label&&og.label.toLowerCase().includes(provider.toLowerCase())){
|
||
providerGroup=og; break;
|
||
}
|
||
}
|
||
if(!providerGroup){
|
||
providerGroup=document.createElement('optgroup');
|
||
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
|
||
sel.appendChild(providerGroup);
|
||
}
|
||
const existingIds=new Set([...sel.options].map(o=>o.value));
|
||
// Normalized dedup: strip one @provider: prefix and namespace so
|
||
// 'minimax/minimax-m2.7' matches '@nous:minimax/minimax-m2.7' (#907).
|
||
const _normId=id=>{
|
||
let s=String(id||'');
|
||
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1);
|
||
s=s.split('/').pop();
|
||
return s.replace(/-/g,'.').toLowerCase();
|
||
};
|
||
const existingNorm=new Set([...sel.options].map(o=>_normId(o.value)));
|
||
let added=0;
|
||
const _ap=(window._activeProvider||'').toLowerCase();
|
||
const _providerLower=String(provider||'').toLowerCase();
|
||
const _isNamedCustomActiveProvider=_ap.startsWith('custom:');
|
||
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && (_providerLower===_ap||_isNamedCustomActiveProvider&&_providerLower===_ap);
|
||
for(const m of models){
|
||
let mid=m.id;
|
||
if(_isPortalFetch && !mid.startsWith('@')){
|
||
mid=`@${provider}:${mid}`;
|
||
}
|
||
if(existingIds.has(mid)) continue;
|
||
if(existingNorm.has(_normId(mid))) continue; // dedup cross-prefix duplicates (#907)
|
||
const opt=document.createElement('option');
|
||
opt.value=mid;
|
||
opt.textContent=m.label||m.id;
|
||
opt.title='Live model — fetched from provider';
|
||
providerGroup.appendChild(opt);
|
||
_dynamicModelLabels[mid]=m.label||m.id;
|
||
added++;
|
||
}
|
||
const currentProvider=(S.session&&S.session.model_provider)||null;
|
||
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider);
|
||
// After live models are added, re-apply the session's model in case it was
|
||
// absent from the static list and syncTopbar() fired before the live fetch
|
||
// completed (#1169). This ensures the session model wins over any premature
|
||
// fallback that may have set sel.value to the first available option.
|
||
if(S.session && S.session.model && sel.id==='modelSelect'){
|
||
const reapplied=_applyModelToDropdown(S.session.model, sel, S.session.model_provider||null);
|
||
if(reapplied && typeof syncModelChip==='function') syncModelChip();
|
||
}
|
||
return added;
|
||
}
|
||
|
||
async function _fetchLiveModels(provider, sel){
|
||
if(!provider||!sel) return;
|
||
// Already fetched — apply cached models to this select element (#872)
|
||
if(_liveModelCache[provider]){
|
||
const added=_addLiveModelsToSelect(provider,_liveModelCache[provider],sel);
|
||
if(added>0 && typeof syncModelChip==='function') syncModelChip();
|
||
return;
|
||
}
|
||
_liveModelFetchPending.add(provider);
|
||
try{
|
||
const url=new URL('api/models/live',document.baseURI||location.href);
|
||
url.searchParams.set('provider',provider);
|
||
const _liveRes=await fetch(url.href,{credentials:'include'});
|
||
if(_redirectIfUnauth(_liveRes)) return;
|
||
const data=await _liveRes.json();
|
||
if(!data.models||!data.models.length) return;
|
||
_liveModelCache[provider]=data.models;
|
||
const added=_addLiveModelsToSelect(provider,data.models,sel);
|
||
if(added>0){
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
console.debug('[hermes] Live models loaded for',provider+':',added,'new models added');
|
||
}
|
||
}catch(e){
|
||
console.debug('[hermes] Live model fetch failed for',provider,e.message);
|
||
}finally{
|
||
_liveModelFetchPending.delete(provider);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if the given model ID belongs to a different provider than the one
|
||
* currently configured in Hermes. Returns a warning string if mismatched,
|
||
* or null if the selection looks compatible.
|
||
*
|
||
* Provider detection is intentionally loose — we compare the model's slash
|
||
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
|
||
* name. Custom/local endpoints report active_provider='custom', a named
|
||
* custom provider such as 'custom:zenmux', or the base_url hostname; skip the
|
||
* check for those values to avoid false positives.
|
||
*/
|
||
function _checkProviderMismatch(modelId){
|
||
const ap=(window._activeProvider||'').toLowerCase();
|
||
if(_providerSkipsModelMismatchWarning(ap)) return null; // can't reliably check
|
||
// @provider: prefixed IDs came from that provider's live model list — no mismatch possible
|
||
if(modelId.startsWith('@')) return null;
|
||
const slash=modelId.indexOf('/');
|
||
if(slash<0) return null; // bare model name, no provider prefix
|
||
const modelProvider=modelId.substring(0,slash).toLowerCase();
|
||
// Normalise common aliases
|
||
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
|
||
const norm=p=>aliases[p]||p;
|
||
if(norm(modelProvider)!==norm(ap)){
|
||
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
|
||
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function _selectedModelOption(){
|
||
const sel=$('modelSelect');
|
||
if(!sel) return null;
|
||
return sel.options[sel.selectedIndex]||null;
|
||
}
|
||
|
||
function _normalizeConfiguredModelKey(modelId){
|
||
let s=String(modelId||'').trim().toLowerCase();
|
||
// Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5).
|
||
// Defensive: trailing-colon / trailing-slash falls back to the original key
|
||
// so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
|
||
if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;}
|
||
if(s.includes('/')){const last=s.split('/').pop();s=last||s;}
|
||
return s.replace(/-/g,'.');
|
||
}
|
||
|
||
function _getConfiguredModelBadge(modelId,badgeMap,providerId){
|
||
const map=badgeMap||window._configuredModelBadges||{};
|
||
if(!modelId||!map) return null;
|
||
const provider=String(providerId||'').toLowerCase();
|
||
const exact=map[modelId];
|
||
if(exact && (!provider || !exact.provider || String(exact.provider).toLowerCase()===provider)) return exact;
|
||
const targetNorm=_normalizeConfiguredModelKey(modelId);
|
||
const matches=[];
|
||
for(const [candidate,badge] of Object.entries(map)){
|
||
if(_normalizeConfiguredModelKey(candidate)===targetNorm) matches.push(badge);
|
||
}
|
||
if(!matches.length) return null;
|
||
if(provider){
|
||
const providerMatch=matches.find(badge=>String(badge&&badge.provider||'').toLowerCase()===provider);
|
||
if(providerMatch) return providerMatch;
|
||
return matches.length===1 ? matches[0] : null;
|
||
}
|
||
return matches[0];
|
||
}
|
||
|
||
function syncModelChip(){
|
||
const sel=$('modelSelect');
|
||
const chip=$('composerModelChip');
|
||
const label=$('composerModelLabel');
|
||
const mobileLabel=$('composerMobileModelLabel');
|
||
const mobileAction=$('composerMobileModelAction');
|
||
const dd=$('composerModelDropdown');
|
||
if(!sel||!chip||!label) return;
|
||
// Don't show a model label until boot has finished loading to prevent flash of wrong default
|
||
if(!S._bootReady){
|
||
label.textContent='';
|
||
if(mobileLabel) mobileLabel.textContent='';
|
||
chip.title='Conversation model';
|
||
return;
|
||
}
|
||
const opt=_selectedModelOption();
|
||
const text=opt?opt.textContent:getModelLabel(sel.value||'');
|
||
const gatewayRouting=_latestGatewayRoutingForSession(S.session);
|
||
const displayText=_formatGatewayModelLabel(sel.value||'',text,gatewayRouting)||text;
|
||
label.textContent=displayText;
|
||
if(mobileLabel) mobileLabel.textContent=displayText;
|
||
chip.title=gatewayRouting?`${sel.value||'Conversation model'} ${_gatewayRoutingLabel(gatewayRouting)}`:(sel.value||'Conversation model');
|
||
chip.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
|
||
if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
|
||
}
|
||
|
||
function _positionModelDropdown(){
|
||
const dd=$('composerModelDropdown');
|
||
const chip=$('composerModelChip');
|
||
const mobileAction=$('composerMobileModelAction');
|
||
const footer=document.querySelector('.composer-footer');
|
||
if(!dd||!footer) return;
|
||
const panel=$('composerMobileConfigPanel');
|
||
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
|
||
if(!anchor) return;
|
||
const chipRect=anchor.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 renderModelDropdown(){
|
||
const dd=$('composerModelDropdown');
|
||
const sel=$('modelSelect');
|
||
if(!dd||!sel) return;
|
||
// Store model data for filtering
|
||
const _modelData=[];
|
||
const _badgeMap=window._configuredModelBadges||{};
|
||
for(const child of Array.from(sel.children)){
|
||
if(child.tagName==='OPTGROUP'){
|
||
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
|
||
for(const opt of Array.from(child.children)){
|
||
const rawValue=String(opt.value||'');
|
||
const displayName=rawValue.startsWith('@custom:')
|
||
? getModelLabel(rawValue)
|
||
: (opt.textContent||getModelLabel(rawValue));
|
||
_modelData.push({value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)});
|
||
}
|
||
}
|
||
if(child.tagName==='OPTION'){
|
||
const rawValue=String(child.value||'');
|
||
const displayName=rawValue.startsWith('@custom:')
|
||
? getModelLabel(rawValue)
|
||
: (child.textContent||getModelLabel(rawValue));
|
||
_modelData.push({value:child.value,name:esc(displayName),id:esc(child.value),group:'',badge:_getConfiguredModelBadge(child.value,_badgeMap)});
|
||
}
|
||
}
|
||
const _existingConfiguredKeys=new Set(_modelData.map(existing=>_normalizeConfiguredModelKey(existing.value)));
|
||
for(const [modelId,badge] of Object.entries(_badgeMap)){
|
||
if(_existingConfiguredKeys.has(_normalizeConfiguredModelKey(modelId))) continue;
|
||
_modelData.push({
|
||
value:modelId,
|
||
name:esc(getModelLabel(modelId)),
|
||
id:esc(modelId),
|
||
group:'',
|
||
badge,
|
||
});
|
||
_existingConfiguredKeys.add(_normalizeConfiguredModelKey(modelId));
|
||
}
|
||
// Create search input FIRST before filterModels definition
|
||
const _scopeNote=document.createElement('div');
|
||
_scopeNote.className='model-scope-note';
|
||
_scopeNote.textContent=t('model_scope_advisory')||'Applies to this conversation from your next message.';
|
||
const _searchRow=document.createElement('div');
|
||
_searchRow.className='model-search-row';
|
||
_searchRow.innerHTML=`<input class="model-search-input" type="text" placeholder="${esc(t('model_search_placeholder')||'Search models…')}" spellcheck="false" autocomplete="off"><button class="model-search-clear" title="Clear search">${li('x',10)}</button>`;
|
||
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=`<input class="model-custom-input" type="text" placeholder="${esc(t('model_custom_placeholder')||'e.g. openai/gpt-5.4')}" spellcheck="false" autocomplete="off"><button class="model-custom-btn" title="Use this model">${li('plus',12)}</button>`;
|
||
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 configuredCandidates=_modelData
|
||
.filter(m=>m.badge&&matches(m));
|
||
const configuredBySemanticKey=new Map();
|
||
const _configuredProviderKey=(m)=>String((m&&m.badge&&m.badge.provider)||_providerFromModelValue(m&&m.value)||'').toLowerCase();
|
||
const _configuredModelKey=(m)=>_normalizeConfiguredModelKey(m&&m.value||'');
|
||
const _configuredDisplayPriority=(m)=>{
|
||
// Prefer plain IDs over provider-qualified aliases for readability.
|
||
const v=String((m&&m.value)||'');
|
||
if(v.startsWith('@')) return 0;
|
||
if(v.includes('/')) return 1;
|
||
return 2;
|
||
};
|
||
for(const candidate of configuredCandidates){
|
||
const semanticKey=`${_configuredProviderKey(candidate)}::${_configuredModelKey(candidate)}`;
|
||
const existing=configuredBySemanticKey.get(semanticKey);
|
||
if(!existing){
|
||
configuredBySemanticKey.set(semanticKey,candidate);
|
||
continue;
|
||
}
|
||
const candidatePriority=_configuredDisplayPriority(candidate);
|
||
const existingPriority=_configuredDisplayPriority(existing);
|
||
if(candidatePriority>existingPriority){
|
||
configuredBySemanticKey.set(semanticKey,candidate);
|
||
}
|
||
}
|
||
const configuredModels=[...configuredBySemanticKey.values()]
|
||
.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);
|
||
// 为了显示原始ID,建立 badgeKeyMap: badge对象->原始key
|
||
const badgeKeyMap = new Map();
|
||
for(const [k, v] of Object.entries(_badgeMap)){
|
||
badgeKeyMap.set(v, k);
|
||
}
|
||
for(const m of configuredModels){
|
||
const row=document.createElement('div');
|
||
row.className='model-opt'+(m.value===sel.value?' active':'');
|
||
let badgeLabel = '';
|
||
let modelName = m.name;
|
||
if (m.badge) {
|
||
// 直接用badge的原始key(即config.yaml里的ID)
|
||
const rawId = badgeKeyMap.get(m.badge) || m.value || m.badge.label || 'Configured';
|
||
badgeLabel = rawId;
|
||
modelName = rawId; // model-opt-name直接用原始ID
|
||
if(m.badge.provider){
|
||
const providerName=m.badge.provider.replace(/^custom:/,'').split('/')[0];
|
||
badgeLabel += ` (${providerName})`;
|
||
}
|
||
}
|
||
const badgeHtml=m.badge?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(badgeLabel)}</span>`:'';
|
||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(modelName)}</span>${badgeHtml}</div><span class="model-opt-id">${esc(m.id)}</span>`;
|
||
row.onclick=()=>selectModelFromDropdown(m.value);
|
||
dd.appendChild(row);
|
||
}
|
||
}
|
||
// Add remaining models matching filter
|
||
let _lastGroup=null;
|
||
// Count models per group for heading labels (#1425)
|
||
const _groupCounts={};
|
||
for(const m of _modelData){
|
||
if(configuredIds.has(m.value)) continue;
|
||
if(m.group) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1;
|
||
}
|
||
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';
|
||
const count=_groupCounts[m.group]||0;
|
||
heading.textContent=count>1?`${m.group} (${count})`: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?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
|
||
// Inline provider chip on every row that has a group (#1425)
|
||
const providerChip=m.group?`<span class="model-opt-provider">${esc(m.group)}</span>`:'';
|
||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${esc(m.name)}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${esc(m.id)}</span>`;
|
||
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();
|
||
}
|
||
|
||
async 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();
|
||
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
|
||
const ready=window._modelDropdownReady;
|
||
if(ready&&typeof ready.then==='function'){
|
||
try{await ready;}catch(_){}
|
||
}
|
||
if(dd.classList.contains('open')) return;
|
||
renderModelDropdown();
|
||
dd.classList.add('open');
|
||
_positionModelDropdown();
|
||
chip.classList.add('active');
|
||
const mobileAction=$('composerMobileModelAction');
|
||
if(mobileAction) mobileAction.classList.add('active');
|
||
}
|
||
|
||
function closeModelDropdown(){
|
||
const dd=$('composerModelDropdown');
|
||
const chip=$('composerModelChip');
|
||
const mobileAction=$('composerMobileModelAction');
|
||
if(dd) dd.classList.remove('open');
|
||
if(chip) chip.classList.remove('active');
|
||
if(mobileAction) mobileAction.classList.remove('active');
|
||
}
|
||
|
||
document.addEventListener('click',e=>{
|
||
if(
|
||
!e.target.closest('#composerModelChip') &&
|
||
!e.target.closest('#composerMobileModelAction') &&
|
||
!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');
|
||
const mobileLabel=$('composerMobileReasoningLabel');
|
||
const mobileAction=$('composerMobileReasoningAction');
|
||
if(!wrap||!label) return;
|
||
wrap.style.display='';
|
||
if(mobileAction) mobileAction.style.display='';
|
||
const text=_formatReasoningEffortLabel(effort);
|
||
label.textContent=text;
|
||
if(mobileLabel) mobileLabel.textContent=text;
|
||
if(chip){
|
||
const inactive=!effort||effort==='none';
|
||
chip.classList.toggle('inactive',inactive);
|
||
chip.title='Reasoning effort: '+text;
|
||
}
|
||
if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none');
|
||
_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();
|
||
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
|
||
_highlightReasoningOption(_currentReasoningEffort);
|
||
dd.classList.add('open');
|
||
_positionReasoningDropdown();
|
||
chip.classList.add('active');
|
||
const mobileAction=$('composerMobileReasoningAction');
|
||
if(mobileAction) mobileAction.classList.add('active');
|
||
}
|
||
|
||
function _positionReasoningDropdown(){
|
||
const dd=$('composerReasoningDropdown');
|
||
const chip=$('composerReasoningChip');
|
||
const mobileAction=$('composerMobileReasoningAction');
|
||
const footer=document.querySelector('.composer-footer');
|
||
if(!dd||!chip||!footer) return;
|
||
const panel=$('composerMobileConfigPanel');
|
||
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:chip;
|
||
const chipRect=anchor.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');
|
||
const mobileAction=$('composerMobileReasoningAction');
|
||
if(dd) dd.classList.remove('open');
|
||
if(chip) chip.classList.remove('active');
|
||
if(mobileAction) mobileAction.classList.remove('active');
|
||
}
|
||
|
||
document.addEventListener('click',function(e){
|
||
if(
|
||
!e.target.closest('#composerReasoningChip') &&
|
||
!e.target.closest('#composerMobileReasoningAction') &&
|
||
!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();
|
||
}
|
||
}
|
||
});
|
||
|
||
// ── Session toolsets chip (#493) ───────────────────────────────────────────
|
||
let _currentSessionToolsets = null; // null = global, array = custom list
|
||
|
||
function _applyToolsetsChip(toolsets) {
|
||
_currentSessionToolsets = toolsets;
|
||
const wrap = $('composerToolsetsWrap');
|
||
const label = $('composerToolsetsLabel');
|
||
const chip = $('composerToolsetsChip');
|
||
if (!wrap || !label) return;
|
||
// Visibility is controlled entirely by responsive CSS — the chip shows only
|
||
// at wide composer-footer widths (>= 1100px container query). At narrower
|
||
// widths the layout is too cramped (model + reasoning + profile + workspace
|
||
// + context-ring + send) to add another chip. Cleared inline style so the
|
||
// CSS @container query is the single source of truth. State is still
|
||
// tracked so /api/session/toolsets continues to work for cron/scripted
|
||
// callers regardless of UI visibility. (#1431)
|
||
wrap.style.display = '';
|
||
const hasCustom = Array.isArray(toolsets) && toolsets.length > 0;
|
||
if (hasCustom) {
|
||
label.textContent = toolsets.join(', ');
|
||
chip.classList.add('has-custom');
|
||
chip.title = t('session_toolsets') + ': ' + toolsets.join(', ');
|
||
} else {
|
||
label.textContent = t('session_toolsets_global');
|
||
chip.classList.remove('has-custom');
|
||
chip.title = t('session_toolsets');
|
||
}
|
||
}
|
||
|
||
function _syncToolsetsChip() {
|
||
if (typeof S === 'undefined' || !S || !S.session) {
|
||
_applyToolsetsChip(null);
|
||
return;
|
||
}
|
||
_applyToolsetsChip(S.session.enabled_toolsets || null);
|
||
}
|
||
|
||
function syncToolsetsChip() {
|
||
_syncToolsetsChip();
|
||
}
|
||
|
||
function _populateToolsetsDropdown() {
|
||
const desc = $('toolsetsDropdownDesc');
|
||
const state = $('toolsetsDropdownState');
|
||
const input = $('toolsetsInput');
|
||
const applyBtn = $('toolsetsApplyBtn');
|
||
const clearBtn = $('toolsetsClearBtn');
|
||
if (!desc || !state || !input) return;
|
||
desc.textContent = t('session_toolsets_desc');
|
||
if (applyBtn) applyBtn.textContent = t('session_toolsets_apply');
|
||
if (clearBtn) clearBtn.textContent = t('session_toolsets_clear');
|
||
input.placeholder = t('session_toolsets_placeholder');
|
||
// Escape key handler for toolsets input
|
||
input.onkeydown = function(e) { if(e.key === 'Escape') closeToolsetsDropdown(); };
|
||
const hasCustom = Array.isArray(_currentSessionToolsets) && _currentSessionToolsets.length > 0;
|
||
if (hasCustom) {
|
||
state.textContent = '🔧 ' + _currentSessionToolsets.join(', ');
|
||
input.value = _currentSessionToolsets.join(', ');
|
||
} else {
|
||
state.textContent = '🌍 ' + t('session_toolsets_global');
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
function _positionToolsetsDropdown() {
|
||
const dd = $('composerToolsetsDropdown');
|
||
const chip = $('composerToolsetsChip');
|
||
const footer = document.querySelector('.composer-footer');
|
||
if (!dd || !chip || !footer) return;
|
||
// Defense: if the chip has been hidden by responsive CSS (e.g. resize across
|
||
// 1100px container threshold while dropdown was open), don't try to anchor
|
||
// to a zero-rect element — close the dropdown instead. (#1431)
|
||
if (chip.offsetParent === null) { closeToolsetsDropdown(); 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 toggleToolsetsDropdown() {
|
||
const dd = $('composerToolsetsDropdown');
|
||
const chip = $('composerToolsetsChip');
|
||
if (!dd || !chip) return;
|
||
if (typeof S === 'undefined' || !S || !S.session) return;
|
||
// Don't open when the chip itself is hidden by responsive CSS (#1431).
|
||
// offsetParent === null catches display:none on the element or any ancestor.
|
||
if (chip.offsetParent === null) return;
|
||
const open = dd.classList.contains('open');
|
||
if (open) { closeToolsetsDropdown(); return; }
|
||
if (typeof closeProfileDropdown === 'function') closeProfileDropdown();
|
||
if (typeof closeWsDropdown === 'function') closeWsDropdown();
|
||
closeModelDropdown();
|
||
if (typeof closeReasoningDropdown === 'function') closeReasoningDropdown();
|
||
_syncToolsetsChip();
|
||
_populateToolsetsDropdown();
|
||
dd.classList.add('open');
|
||
_positionToolsetsDropdown();
|
||
chip.classList.add('active');
|
||
// Focus the input after a tick so the layout has settled
|
||
setTimeout(() => { const inp = $('toolsetsInput'); if (inp) inp.focus(); }, 50);
|
||
}
|
||
|
||
function closeToolsetsDropdown() {
|
||
const dd = $('composerToolsetsDropdown');
|
||
const chip = $('composerToolsetsChip');
|
||
if (dd) dd.classList.remove('open');
|
||
if (chip) chip.classList.remove('active');
|
||
}
|
||
|
||
function _applySessionToolsets(toolsets) {
|
||
if (typeof S === 'undefined' || !S || !S.session) return;
|
||
const sid = S.session.session_id;
|
||
api('/api/session/toolsets', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session_id: sid, toolsets: toolsets })
|
||
})
|
||
.then(function(r) {
|
||
if (r && r.ok) {
|
||
S.session.enabled_toolsets = r.enabled_toolsets || null;
|
||
_applyToolsetsChip(r.enabled_toolsets || null);
|
||
if (r.enabled_toolsets && r.enabled_toolsets.length) {
|
||
showToast('🔧 ' + t('session_toolsets_applied') + ': ' + r.enabled_toolsets.join(', '));
|
||
} else {
|
||
showToast('🌍 ' + t('session_toolsets_cleared'));
|
||
}
|
||
} else {
|
||
showToast(t('session_toolsets_failed') + (r && r.error ? r.error : 'Unknown error'), 3000, 'error');
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
showToast(t('session_toolsets_failed') + (err.message || err), 3000, 'error');
|
||
});
|
||
}
|
||
|
||
// Click-outside handler for toolsets dropdown
|
||
document.addEventListener('click', function(e) {
|
||
if (
|
||
!e.target.closest('#composerToolsetsChip') &&
|
||
!e.target.closest('#composerToolsetsDropdown')
|
||
) closeToolsetsDropdown();
|
||
// Apply button
|
||
if (e.target.closest('#toolsetsApplyBtn')) {
|
||
const input = $('toolsetsInput');
|
||
if (!input) return;
|
||
const raw = input.value.trim();
|
||
if (!raw) {
|
||
showToast(t('session_toolsets_desc'), 2000);
|
||
return;
|
||
}
|
||
const toolsets = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||
if (toolsets.length === 0) {
|
||
showToast(t('session_toolsets_desc'), 2000);
|
||
return;
|
||
}
|
||
_applySessionToolsets(toolsets);
|
||
closeToolsetsDropdown();
|
||
}
|
||
// Clear button
|
||
if (e.target.closest('#toolsetsClearBtn')) {
|
||
_applySessionToolsets(null);
|
||
closeToolsetsDropdown();
|
||
}
|
||
});
|
||
|
||
// Position toolsets dropdown on resize, OR close it if the chip is no longer
|
||
// visible (e.g. resize crossed the 1100px container threshold while dropdown
|
||
// was open — the wrap is hidden by CSS but the dropdown sibling stays open
|
||
// without an anchor). (#1431)
|
||
window.addEventListener('resize', () => {
|
||
const dd = $('composerToolsetsDropdown');
|
||
if (!dd || !dd.classList.contains('open')) return;
|
||
const chip = $('composerToolsetsChip');
|
||
if (!chip || chip.offsetParent === null) { closeToolsetsDropdown(); return; }
|
||
_positionToolsetsDropdown();
|
||
});
|
||
|
||
function _syncMobileComposerConfigButton(open){
|
||
const btn=$('composerMobileConfigBtn');
|
||
if(!btn) return;
|
||
btn.classList.toggle('active',!!open);
|
||
btn.setAttribute('aria-expanded',open?'true':'false');
|
||
}
|
||
|
||
function closeMobileComposerConfig(){
|
||
const panel=$('composerMobileConfigPanel');
|
||
if(panel) panel.classList.remove('open');
|
||
_syncMobileComposerConfigButton(false);
|
||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||
}
|
||
|
||
function toggleMobileComposerConfig(){
|
||
const panel=$('composerMobileConfigPanel');
|
||
if(!panel) return;
|
||
const open=panel.classList.contains('open');
|
||
if(open){
|
||
closeMobileComposerConfig();
|
||
closeModelDropdown();
|
||
closeReasoningDropdown();
|
||
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
|
||
return;
|
||
}
|
||
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||
closeModelDropdown();
|
||
closeReasoningDropdown();
|
||
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
|
||
panel.classList.add('open');
|
||
_syncMobileComposerConfigButton(true);
|
||
}
|
||
|
||
document.addEventListener('click',function(e){
|
||
if(
|
||
e.target.closest('#composerMobileConfigBtn') ||
|
||
e.target.closest('#composerMobileConfigPanel') ||
|
||
e.target.closest('#composerWsDropdown') ||
|
||
e.target.closest('#composerModelDropdown') ||
|
||
e.target.closest('#composerReasoningDropdown')
|
||
) return;
|
||
closeMobileComposerConfig();
|
||
});
|
||
|
||
document.addEventListener('keydown',function(e){
|
||
if(e.key!=='Escape') return;
|
||
const panel=$('composerMobileConfigPanel');
|
||
if(!panel||!panel.classList.contains('open')) return;
|
||
e.preventDefault();
|
||
closeMobileComposerConfig();
|
||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||
closeModelDropdown();
|
||
closeReasoningDropdown();
|
||
});
|
||
|
||
window.addEventListener('resize',function(){
|
||
if(window.matchMedia && !window.matchMedia('(max-width: 640px)').matches){
|
||
closeMobileComposerConfig();
|
||
closeModelDropdown();
|
||
closeReasoningDropdown();
|
||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||
}
|
||
});
|
||
|
||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||
// Once the user scrolls back to within 250px of the bottom, re-pin.
|
||
// Uses a guard flag to avoid the race where programmatic scrolls (from
|
||
// scrollIfPinned / scrollToBottom) re-set _scrollPinned=true, overriding
|
||
// the user's explicit scroll-up. Fixes #1469 / #1360.
|
||
// Direction-aware unpin (issue #1731): the hysteresis below is correct
|
||
// for re-pinning (entering the near-bottom zone), but applying it to
|
||
// unpinning stranded users who scrolled up by a small amount inside the
|
||
// 250px zone — every upward sample still landed in the near-bottom
|
||
// region, so the counter kept incrementing and _scrollPinned stayed
|
||
// true. The next streaming token snapped them back. We now track
|
||
// scrollTop direction: an explicit upward movement (scrollTop decreased
|
||
// by more than 2px between samples) unpins immediately and resets the
|
||
// counter, while downward / stationary movement falls through the
|
||
// original hysteresis path so the macOS momentum re-pin protection from
|
||
// #1360 is preserved.
|
||
// rAF-debounced scroll listener (issue #1360): on macOS WKWebView, trackpad
|
||
// momentum scrolling fires scroll events that interleave with the
|
||
// _programmaticScroll setTimeout(0) guard. A mid-momentum scroll event can
|
||
// either get swallowed (_programmaticScroll still true) or falsely report
|
||
// the user is at the bottom (momentum hasn't settled). rAF defers the
|
||
// distance check to the next paint frame when the browser's scroll
|
||
// position has settled. A hysteresis counter requires two consecutive
|
||
// near-bottom samples before re-pinning, preventing accidental re-pin
|
||
// during initial deceleration.
|
||
let _scrollPinned=true;
|
||
let _programmaticScroll=false;
|
||
let _nearBottomCount=0;
|
||
let _lastScrollTop=null;
|
||
let _lastNonMessageScrollIntentMs=0;
|
||
let _lastMessageUpwardIntentMs=0;
|
||
let _messageUserUnpinned=false;
|
||
let _bottomSettleToken=0;
|
||
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
|
||
const MESSAGE_UPWARD_INTENT_MS=450;
|
||
function _cancelBottomSettle(){ _bottomSettleToken++; }
|
||
function _recordNonMessageScrollIntent(e){
|
||
const el=document.getElementById('messages');
|
||
const target=e&&e.target;
|
||
if(!el||!target) return;
|
||
// Streaming token renders should keep pinning the chat only while the user is
|
||
// actually interacting with the chat pane. A wheel/touch gesture over the
|
||
// session sidebar (or another independent pane) must not be immediately fought
|
||
// by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
|
||
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
|
||
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
|
||
// User is intentionally moving upward in the transcript. Record the real
|
||
// input event so later scrollTop decreases caused by layout/windowing do
|
||
// not masquerade as user intent and strand live streaming away from bottom.
|
||
_lastMessageUpwardIntentMs=performance.now();
|
||
// User is intentionally moving in the transcript. Cancel any delayed
|
||
// scrollToBottom settling that was scheduled by session-load/layout growth.
|
||
_cancelBottomSettle();
|
||
if(typeof e.deltaY==='number'&&e.deltaY<0){
|
||
_messageUserUnpinned=true;
|
||
_nearBottomCount=0;
|
||
_scrollPinned=false;
|
||
}
|
||
}
|
||
}
|
||
function _recentMessageUpwardIntent(){
|
||
return performance.now()-_lastMessageUpwardIntentMs<MESSAGE_UPWARD_INTENT_MS;
|
||
}
|
||
function _recentNonMessageScrollIntent(){
|
||
return performance.now()-_lastNonMessageScrollIntentMs<NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS;
|
||
}
|
||
if(typeof document!=='undefined'){
|
||
document.addEventListener('wheel',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||
document.addEventListener('touchmove',_recordNonMessageScrollIntent,{capture:true,passive:true});
|
||
}
|
||
// Reset hook for session-switch — called from sessions.js loadSession() to
|
||
// prevent the new chat's first scroll comparing against the previous chat's
|
||
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
|
||
function _resetScrollDirectionTracker(){ _lastScrollTop=null; }
|
||
if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
|
||
/* ── Pull-to-refresh for PWA standalone (Android) ── */
|
||
(function(){
|
||
if(typeof document==='undefined') return;
|
||
const isStandalone=window.navigator?.standalone||matchMedia('(display-mode:standalone),(display-mode:fullscreen)').matches;
|
||
if(!isStandalone) return;
|
||
const el=document.getElementById('messages');
|
||
if(!el) return;
|
||
let _ptrState=0; // 0=idle, 1=pulling, 2=ready
|
||
let _ptrStartY=0;
|
||
let _ptrCurrentY=0;
|
||
const THRESHOLD=80;
|
||
let _indicator=null;
|
||
function _ptrCreateIndicator(){
|
||
if(_indicator) return;
|
||
_indicator=document.createElement('div');
|
||
_indicator.className='pull-to-refresh-indicator';
|
||
_indicator.innerHTML='<span class="ptr-icon">↓</span> <span class="ptr-text">Pull to refresh</span>';
|
||
el.parentNode.insertBefore(_indicator,el);
|
||
}
|
||
function _ptrUpdate(progress){
|
||
_ptrCreateIndicator();
|
||
const pulling=progress<1;
|
||
_indicator.classList.toggle('active',progress>0);
|
||
const icon=_indicator.querySelector('.ptr-icon');
|
||
const text=_indicator.querySelector('.ptr-text');
|
||
if(icon) icon.classList.toggle('ready',!pulling);
|
||
if(text) text.textContent=pulling?'Pull to refresh':'Release to refresh';
|
||
}
|
||
function _ptrReset(){
|
||
_ptrState=0;
|
||
_ptrStartY=0;
|
||
_ptrCurrentY=0;
|
||
if(_indicator) _indicator.classList.remove('active');
|
||
}
|
||
el.addEventListener('touchstart',function(e){
|
||
if(el.scrollTop>0||_ptrState!==0) return;
|
||
_ptrStartY=e.touches[0].clientY;
|
||
_ptrState=1;
|
||
},{passive:true});
|
||
el.addEventListener('touchmove',function(e){
|
||
if(_ptrState!==1) return;
|
||
_ptrCurrentY=e.touches[0].clientY;
|
||
const pull=_ptrCurrentY-_ptrStartY;
|
||
if(pull<0){ _ptrReset(); return; }
|
||
/* If not at the top, smooth-scroll to top first.
|
||
Next pull gesture will trigger the refresh. */
|
||
if(el.scrollTop>0){
|
||
el.scrollTo({top:0,behavior:'smooth'});
|
||
_ptrReset();
|
||
return;
|
||
}
|
||
const progress=Math.min(pull/THRESHOLD,1);
|
||
_ptrUpdate(progress);
|
||
_ptrState=progress>=1?2:1;
|
||
if(progress>0.3) e.preventDefault();
|
||
},{passive:false});
|
||
el.addEventListener('touchend',function(){
|
||
if(_ptrState===2){ window.location.reload(); return; }
|
||
_ptrReset();
|
||
},{passive:true});
|
||
el.addEventListener('touchcancel',_ptrReset,{passive:true});
|
||
})();
|
||
(function(){
|
||
const el=document.getElementById('messages');
|
||
if(!el) return;
|
||
let _scrollRaf=0;
|
||
el.addEventListener('scroll',()=>{
|
||
if(_programmaticScroll) return; // ignore scrolls we triggered ourselves
|
||
cancelAnimationFrame(_scrollRaf);
|
||
_scrollRaf=requestAnimationFrame(()=>{
|
||
const top=el.scrollTop;
|
||
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
|
||
// scrollToBottomBtn visibility is updated below after pin state settles.
|
||
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent();
|
||
_lastScrollTop=top;
|
||
if(movedUp){ _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731
|
||
else {
|
||
if(nearBottom){
|
||
_nearBottomCount=_nearBottomCount+1;
|
||
if(_nearBottomCount>=2) _scrollPinned=true;
|
||
} else { _nearBottomCount=0; _scrollPinned=false; }
|
||
if(_scrollPinned) _messageUserUnpinned=false;
|
||
} // #1360
|
||
const btn=$('scrollToBottomBtn');
|
||
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
|
||
if(btn) btn.style.display=showBottomButton?'flex':'none';
|
||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||
// Prefetch older messages before the reader hits the hard top. Prepending
|
||
// then preserving scrollTop is seamless only if there is runway left for
|
||
// the user's continued upward wheel/touch movement.
|
||
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
|
||
if(_isSessionEndlessScrollEnabled()&&el.scrollTop<olderPrefetchPx && 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);}
|
||
function _formatTurnDuration(seconds){
|
||
const n=Number(seconds);
|
||
if(!Number.isFinite(n)||n<0)return'';
|
||
const total=Math.max(0,Math.round(n));
|
||
if(total<60)return`${total}s`;
|
||
const h=Math.floor(total/3600);
|
||
const m=Math.floor((total%3600)/60);
|
||
const s=total%60;
|
||
if(h)return`${h}h ${m}m`;
|
||
return`${m}m ${s}s`;
|
||
}
|
||
function _formatActiveElapsedTimer(seconds){
|
||
const n=Number(seconds);
|
||
if(!Number.isFinite(n)||n<0)return'';
|
||
const total=Math.max(0,Math.floor(n));
|
||
const m=Math.floor(total/60);
|
||
const s=total%60;
|
||
return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||
}
|
||
const _COMPRESSION_ELAPSED_MAX_SECONDS=5*60;
|
||
let _compressionElapsedTimer=null;
|
||
function _compressionElapsedStartedAt(state){const n=Number(state&&state.startedAt);return Number.isFinite(n)&&n>0?n:null;}
|
||
function _compressionElapsedLabel(state){
|
||
const started=_compressionElapsedStartedAt(state);
|
||
if(!started)return'';
|
||
const elapsed=Math.max(0,(Date.now()/1000)-started);
|
||
if(elapsed>=_COMPRESSION_ELAPSED_MAX_SECONDS)return '5+ min';
|
||
return _formatActiveElapsedTimer(elapsed);
|
||
}
|
||
function _compressionElapsedExpired(state){const started=_compressionElapsedStartedAt(state);return !!(started&&((Date.now()/1000)-started)>=_COMPRESSION_ELAPSED_MAX_SECONDS);}
|
||
function _compressionLiveCardNode(){return document.querySelector('[data-live-compression-card="1"][data-compression-started-at]');}
|
||
function _compressionLiveCardState(){
|
||
const node=_compressionLiveCardNode();
|
||
const started=Number(node&&node.getAttribute('data-compression-started-at'));
|
||
if(!node||!S.session||!Number.isFinite(started)||started<=0)return null;
|
||
return {sessionId:S.session.session_id,phase:'running',automatic:true,message:node.getAttribute('data-compression-message')||'Auto-compressing context...',startedAt:started};
|
||
}
|
||
function _updateCompressionElapsedCards(state){
|
||
if(!state)return false;
|
||
const preview=_autoCompressionPreviewText(state), detail=_autoCompressionDetailText(state);
|
||
let updated=false;
|
||
document.querySelectorAll('.tool-card-compress-auto.tool-card-compress-running').forEach(card=>{
|
||
const previewEl=card.querySelector('.tool-card-preview');
|
||
const detailEl=card.querySelector('.tool-card-result pre');
|
||
if(previewEl) previewEl.textContent=preview;
|
||
if(detailEl) detailEl.textContent=detail;
|
||
updated=true;
|
||
});
|
||
return updated;
|
||
}
|
||
function _updateCompressionElapsedTimer(){
|
||
const state=_compressionStateForCurrentSession()||_compressionLiveCardState();
|
||
if(state&&state.automatic&&state.phase==='running'){
|
||
_updateCompressionElapsedCards(state);
|
||
if(_compressionElapsedExpired(state)) _clearCompressionElapsedTimer();
|
||
}else _clearCompressionElapsedTimer();
|
||
}
|
||
function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressionElapsedTimer=setInterval(_updateCompressionElapsedTimer,1000);}
|
||
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
|
||
let _activityElapsedTimer=null;
|
||
let _activityElapsedTimerGroup=null;
|
||
function _activityElapsedStartedAt(group){
|
||
if(!group)return null;
|
||
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
|
||
?group.dataset.turnStartedAt
|
||
:(S.session&&S.session.pending_started_at);
|
||
const started=Number(raw);
|
||
return Number.isFinite(started)&&started>0?started:null;
|
||
}
|
||
function _activityElapsedLabel(group){
|
||
const started=_activityElapsedStartedAt(group);
|
||
if(!started)return'';
|
||
return _formatActiveElapsedTimer((Date.now()/1000)-started);
|
||
}
|
||
function _setActivityElapsedStartedAt(group){
|
||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
|
||
const started=_activityElapsedStartedAt(group);
|
||
if(started)group.setAttribute('data-turn-started-at',String(started));
|
||
}
|
||
function _updateActiveActivityElapsedTimer(){
|
||
const group=_activityElapsedTimerGroup;
|
||
if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'){
|
||
_clearActivityElapsedTimer();
|
||
return;
|
||
}
|
||
const durationEl=group.querySelector('.tool-call-group-duration');
|
||
const label=_activityElapsedLabel(group);
|
||
if(label){
|
||
group.setAttribute('data-active-turn-elapsed',label);
|
||
}else{
|
||
group.removeAttribute('data-active-turn-elapsed');
|
||
}
|
||
if(durationEl){
|
||
durationEl.textContent=label?`Working ${label}`:'';
|
||
durationEl.style.display=label?'':'none';
|
||
}
|
||
}
|
||
function _startActivityElapsedTimer(group){
|
||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
|
||
_setActivityElapsedStartedAt(group);
|
||
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup!==group)_clearActivityElapsedTimer();
|
||
_activityElapsedTimerGroup=group;
|
||
_updateActiveActivityElapsedTimer();
|
||
if(!_activityElapsedTimer)_activityElapsedTimer=setInterval(_updateActiveActivityElapsedTimer,1000);
|
||
}
|
||
function _clearActivityElapsedTimer(){
|
||
if(_activityElapsedTimer){
|
||
clearInterval(_activityElapsedTimer);
|
||
_activityElapsedTimer=null;
|
||
}
|
||
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup.isConnected){
|
||
_activityElapsedTimerGroup.removeAttribute('data-active-turn-elapsed');
|
||
const durationEl=_activityElapsedTimerGroup.querySelector('.tool-call-group-duration');
|
||
if(durationEl){durationEl.textContent='';durationEl.style.display='none';}
|
||
}
|
||
_activityElapsedTimerGroup=null;
|
||
}
|
||
|
||
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings';
|
||
|
||
function _setCtxCompressButton(btn,text){
|
||
if(!btn)return;
|
||
if(text){
|
||
btn.style.display='';
|
||
btn.textContent=text;
|
||
btn.onclick=function(e){
|
||
if(e)e.stopPropagation();
|
||
const ta=$('msg');
|
||
if(ta){ta.value='/compress ';ta.focus();autoResize();}
|
||
};
|
||
}else{
|
||
btn.style.display='none';
|
||
btn.textContent='';
|
||
btn.onclick=null;
|
||
}
|
||
}
|
||
|
||
function _syncMobileCtxDisplay(state){
|
||
const badge=$('composerMobileCtxBadge');
|
||
const mobileConfigBtn=$('composerMobileConfigBtn');
|
||
const row=$('composerMobileContextAction');
|
||
const usageLine=$('composerMobileContextUsage');
|
||
const tokensLine=$('composerMobileContextTokens');
|
||
const thresholdLine=$('composerMobileContextThreshold');
|
||
const costLine=$('composerMobileContextCost');
|
||
const compressBtn=$('composerMobileCtxCompressBtn');
|
||
if(!state||!state.visible){
|
||
if(badge)badge.style.display='none';
|
||
if(row)row.style.display='none';
|
||
if(mobileConfigBtn){
|
||
mobileConfigBtn.setAttribute('aria-label',_MOBILE_CONFIG_BASE_LABEL);
|
||
mobileConfigBtn.setAttribute('title',_MOBILE_CONFIG_BASE_LABEL);
|
||
}
|
||
_setCtxCompressButton(compressBtn,'');
|
||
return;
|
||
}
|
||
if(badge){
|
||
badge.style.display='inline-flex';
|
||
badge.textContent=state.hasPromptTok?String(state.pct):'\u00b7';
|
||
badge.classList.toggle('ctx-mid',state.pct>50&&state.pct<=75);
|
||
badge.classList.toggle('ctx-high',state.pct>75);
|
||
badge.setAttribute('title',state.label);
|
||
}
|
||
if(mobileConfigBtn){
|
||
mobileConfigBtn.setAttribute('aria-label',`${_MOBILE_CONFIG_BASE_LABEL}; ${state.label}`);
|
||
mobileConfigBtn.setAttribute('title',`${_MOBILE_CONFIG_BASE_LABEL} \u00b7 ${state.label}`);
|
||
}
|
||
if(row){
|
||
row.style.display='';
|
||
row.setAttribute('aria-label',state.label);
|
||
row.classList.toggle('ctx-mid',state.pct>50&&state.pct<=75);
|
||
row.classList.toggle('ctx-high',state.pct>75);
|
||
}
|
||
if(usageLine)usageLine.textContent=state.usageText||'';
|
||
if(tokensLine)tokensLine.textContent=state.tokensText||'';
|
||
if(thresholdLine){
|
||
if(state.thresholdText){
|
||
thresholdLine.style.display='';
|
||
thresholdLine.textContent=state.thresholdText;
|
||
}else{
|
||
thresholdLine.style.display='none';
|
||
thresholdLine.textContent='';
|
||
}
|
||
}
|
||
if(costLine){
|
||
if(state.costText){
|
||
costLine.style.display='';
|
||
costLine.textContent=state.costText;
|
||
}else{
|
||
costLine.style.display='none';
|
||
costLine.textContent='';
|
||
}
|
||
}
|
||
_setCtxCompressButton(compressBtn,state.compressText||'');
|
||
}
|
||
|
||
// Context usage indicator in composer footer
|
||
function _syncCtxIndicator(usage){
|
||
const wrap=$('ctxIndicatorWrap');
|
||
const el=$('ctxIndicator');
|
||
if(!el)return;
|
||
// #1436: Use last_prompt_tokens only — NEVER fall back to cumulative
|
||
// input_tokens for the "context window % used" calculation. input_tokens
|
||
// is summed across all turns, so dividing it by the context window gives a
|
||
// nonsense percentage (often >100%) on long sessions. When we have no
|
||
// last-prompt data we render "·" + "tokens used" via the !hasPromptTok
|
||
// branch below — honest "no data" instead of misleading "890% used".
|
||
const promptTok=usage.last_prompt_tokens||0;
|
||
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
|
||
const cacheReadTok=usage.cache_read_tokens||0;
|
||
const cacheWriteTok=usage.cache_write_tokens||0;
|
||
// Default context window to 128K when not provided by backend
|
||
const DEFAULT_CTX=128*1024;
|
||
const ctxWindow=usage.context_length||DEFAULT_CTX;
|
||
const cost=usage.estimated_cost;
|
||
// Show indicator whenever we have any usage data (tokens or cost)
|
||
if(!promptTok&&!totalTok&&!cost&&!cacheReadTok&&!cacheWriteTok){
|
||
if(wrap) wrap.style.display='none';
|
||
_syncMobileCtxDisplay({visible:false});
|
||
return;
|
||
}
|
||
if(wrap) wrap.style.display='';
|
||
const hasPromptTok=!!promptTok;
|
||
const rawPct=hasPromptTok?Math.round((promptTok/ctxWindow)*100):0;
|
||
const pct=Math.min(100,rawPct);
|
||
const overflowed=rawPct>100;
|
||
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=hasPromptTok?String(pct):'\u00b7';
|
||
const hasExplicitCtx=!!usage.context_length;
|
||
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');
|
||
const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):'');
|
||
if(compressWrap) compressWrap.style.display=compressText?'':'none';
|
||
_setCtxCompressButton(compressBtn,compressText);
|
||
const cacheTotalTok=cacheReadTok+cacheWriteTok;
|
||
const cacheHitPct=cacheTotalTok?Math.round((cacheReadTok/cacheTotalTok)*100):null;
|
||
const cacheText=cacheTotalTok?`cache: ${cacheHitPct}% hit (${_fmtTokens(cacheReadTok)} read / ${_fmtTokens(cacheWriteTok)} write)`:'';
|
||
let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
|
||
if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)';
|
||
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||
if(cacheText) label+=` \u00b7 ${cacheText}`;
|
||
el.setAttribute('aria-label',label);
|
||
const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`;
|
||
const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
|
||
if(usageLine) usageLine.textContent=usageText;
|
||
if(tokensLine) tokensLine.textContent=tokensText;
|
||
const threshold=usage.threshold_tokens||0;
|
||
let thresholdText='';
|
||
if(thresholdLine){
|
||
if(threshold&&ctxWindow){
|
||
thresholdText=`Auto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`;
|
||
thresholdLine.style.display='';
|
||
thresholdLine.textContent=thresholdText;
|
||
}else{
|
||
thresholdLine.style.display='none';
|
||
thresholdLine.textContent='';
|
||
}
|
||
}
|
||
let costText='';
|
||
if(costLine){
|
||
if(cost){
|
||
costText=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||
if(cacheText) costText+=` \u00b7 ${cacheText}`;
|
||
costLine.style.display='';
|
||
costLine.textContent=costText;
|
||
}else if(cacheText){
|
||
costText=cacheText;
|
||
costLine.style.display='';
|
||
costLine.textContent=costText;
|
||
}else{
|
||
costLine.style.display='none';
|
||
costLine.textContent='';
|
||
}
|
||
}
|
||
_syncMobileCtxDisplay({
|
||
visible:true,
|
||
hasPromptTok,
|
||
pct,
|
||
label,
|
||
usageText,
|
||
tokensText,
|
||
thresholdText,
|
||
costText,
|
||
compressText
|
||
});
|
||
}
|
||
|
||
// ── 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 _setMessageScrollToBottom(){
|
||
const el=$('messages');
|
||
if(!el) return;
|
||
_programmaticScroll=true;
|
||
el.scrollTop=el.scrollHeight;
|
||
_lastScrollTop=el.scrollTop;
|
||
_nearBottomCount=2;
|
||
_scrollPinned=true;
|
||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||
}
|
||
function _isMessagePaneNearBottom(threshold=250){
|
||
const el=$('messages');
|
||
if(!el) return false;
|
||
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
|
||
}
|
||
function _shouldFollowMessagesOnDomReplace(){
|
||
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
|
||
}
|
||
function _settleMessageScrollToBottom(force){
|
||
// Markdown post-processing (Prism, tables, Mermaid/KaTeX/PDF placeholders)
|
||
// can grow the transcript after the first scroll write. Re-apply the bottom
|
||
// position across a few frames while pinned so late layout does not leave the
|
||
// viewport a few lines above the real end. User scroll increments
|
||
// _bottomSettleToken and cancels the delayed passes.
|
||
const token=++_bottomSettleToken;
|
||
const passes=[0,16,80,180];
|
||
passes.forEach(delay=>setTimeout(()=>{
|
||
if(token!==_bottomSettleToken) return;
|
||
if(!force && (!_scrollPinned||_recentNonMessageScrollIntent())) return;
|
||
_setMessageScrollToBottom();
|
||
},delay));
|
||
requestAnimationFrame(()=>{
|
||
if(token!==_bottomSettleToken) return;
|
||
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
||
requestAnimationFrame(()=>{
|
||
if(token!==_bottomSettleToken) return;
|
||
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
|
||
});
|
||
});
|
||
}
|
||
function scrollIfPinned(){
|
||
if(!_scrollPinned) return;
|
||
if(_recentNonMessageScrollIntent()) return;
|
||
_settleMessageScrollToBottom(false);
|
||
}
|
||
function scrollToBottom(){
|
||
_scrollPinned=true;
|
||
_messageUserUnpinned=false;
|
||
// Write the first bottom position synchronously. A final renderMessages()
|
||
// rebuild can queue a native scroll event from the temporary scrollTop=0
|
||
// layout state; if we only schedule delayed settles, that event can cancel
|
||
// them before the viewport ever reaches the bottom.
|
||
_setMessageScrollToBottom();
|
||
_settleMessageScrollToBottom(true);
|
||
const btn=$('scrollToBottomBtn');
|
||
if(btn) btn.style.display='none';
|
||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||
}
|
||
|
||
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';
|
||
const rawId=String(modelId||'');
|
||
// Preserve custom gateway model IDs exactly as configured.
|
||
// Examples:
|
||
// @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B
|
||
// @custom:qwen397b-64k -> qwen397b-64k
|
||
if(rawId.startsWith('@custom:')){
|
||
const rest=rawId.slice('@custom:'.length);
|
||
if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId;
|
||
if(rest.includes('/')) return rest.split('/').pop()||rawId;
|
||
return rest||rawId;
|
||
}
|
||
// 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);
|
||
const atProvider=(rawId.startsWith('@')&&rawId.includes(':'))
|
||
? rawId.slice(1,rawId.indexOf(':')).toLowerCase()
|
||
: '';
|
||
const allowOllamaFormat=!atProvider||atProvider.startsWith('ollama');
|
||
// 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 (allowOllamaFormat && (modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) {
|
||
return ollamaLabel;
|
||
}
|
||
return _last || 'Unknown';
|
||
}
|
||
|
||
function _gatewayProviderName(provider){
|
||
const text=String(provider||'').trim();
|
||
if(!text)return'';
|
||
return text.replace(/^custom:/,'').replace(/[-_]/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
|
||
}
|
||
function _gatewayRoutingLabel(routing){
|
||
if(!routing)return'';
|
||
const provider=_gatewayProviderName(routing.used_provider||routing.provider);
|
||
return provider?`via ${provider}`:'';
|
||
}
|
||
function _formatGatewayModelLabel(modelId,labelText,routing){
|
||
if(!routing)return'';
|
||
const usedModel=String(routing.used_model||'').trim();
|
||
const base=usedModel?getModelLabel(usedModel):(labelText||getModelLabel(modelId));
|
||
const via=_gatewayRoutingLabel(routing);
|
||
return via?`${base} ${via}`:base;
|
||
}
|
||
function _gatewayRoutingFailoverText(routing){
|
||
if(!routing||!routing.has_failover)return'';
|
||
const attempts=Array.isArray(routing.routing)?routing.routing:[];
|
||
const providers=attempts.map(a=>_gatewayProviderName(a&&a.provider)).filter(Boolean);
|
||
const unique=[];providers.forEach(p=>{if(!unique.includes(p))unique.push(p);});
|
||
if(unique.length>=2)return`Failover: ${unique[0]} → ${unique[unique.length-1]}`;
|
||
const from=_gatewayProviderName(routing.requested_provider);
|
||
const to=_gatewayProviderName(routing.used_provider);
|
||
if(from&&to&&from!==to)return`Failover: ${from} → ${to}`;
|
||
return'Gateway failover detected';
|
||
}
|
||
function _gatewayModelWarningText(routing){
|
||
if(!routing||!routing.model_changed)return'';
|
||
const requested=getModelLabel(routing.requested_model||'requested model');
|
||
const used=getModelLabel(routing.used_model||'served model');
|
||
return`Model switched: ${requested} → ${used}`;
|
||
}
|
||
function _latestGatewayRoutingForSession(session){
|
||
if(!session)return null;
|
||
if(session.gateway_routing)return session.gateway_routing;
|
||
const history=Array.isArray(session.gateway_routing_history)?session.gateway_routing_history:[];
|
||
return history.length?history[history.length-1]:null;
|
||
}
|
||
|
||
function _stripXmlToolCallsDisplay(s){
|
||
// Strip <function_calls>...</function_calls> 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 <function_calls> 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 _stripVisibleAssistantEchoFromThinking(thinkingText, visibleText){
|
||
let out=String(thinkingText||'');
|
||
const visible=String(visibleText||'');
|
||
if(!out||!visible) return out.trim();
|
||
visible.split(/\n{2,}/).map(s=>s.trim()).filter(s=>s.length>=20).forEach(snippet=>{
|
||
out=out.split(snippet).join('');
|
||
});
|
||
return out.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 backtick fence
|
||
let fenceLen=0;
|
||
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('<blockquote>'+rendered+'</blockquote>');
|
||
// Surround the token with blank lines so the paragraph splitter
|
||
// isolates it as its own chunk (otherwise the token gets wrapped
|
||
// in <p>...<br> 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<lines.length;i++){
|
||
const line=lines[i];
|
||
if(inFence){
|
||
out.push(line);
|
||
if(_isBacktickFenceClose(line,fenceLen)){inFence=false;fenceLen=0;}
|
||
continue;
|
||
}
|
||
const fenceOpen=_matchBacktickFenceLine(line);
|
||
if(fenceOpen){
|
||
flush(i);
|
||
out.push(line);
|
||
inFence=true;
|
||
fenceLen=fenceOpen.len;
|
||
continue;
|
||
}
|
||
if(/^>/.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:<path-or-url> tokens emitted by the agent (e.g. screenshots,
|
||
// generated images) and replace them with inline <img> 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 <pre><code> 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 <ul>/<li> inside <pre>,
|
||
// breaking </pre> closure and corrupting all subsequent message rendering.
|
||
const _preBlock_stash=[];
|
||
const fence_stash=[];
|
||
// CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent)
|
||
// and closing fence must start a line with the same backtick char and at least
|
||
// as many backticks as the opener. Without line/fence-length anchoring, a literal
|
||
// ``` inside a code block (e.g. a nested markdown example) terminates the outer
|
||
// block at the wrong place, leaking content into the markdown stream where
|
||
// bold/italic/inline-code passes corrupt it. Fixes #1438 and #1696.
|
||
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
|
||
const langInfo=(info||'').trim();
|
||
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
|
||
const lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
|
||
code=code||'';
|
||
const codeLines=code.split('\n');
|
||
const firstCodeLine=codeLines.find(line=>line.trim())||'';
|
||
const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||'';
|
||
const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine);
|
||
const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine);
|
||
if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){
|
||
const id='mermaid-'+Math.random().toString(36).slice(2,10);
|
||
_preBlock_stash.push(`<div class="mermaid-block" data-mermaid-id="${id}">${esc(code.trim())}</div>`);
|
||
} else {
|
||
const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';
|
||
const langAttr=lang?` class="language-${esc(lang)}"`:'';
|
||
// For diff/patch blocks, wrap each line in a colored span
|
||
if(lang==='diff'||lang==='patch'){
|
||
const colored=esc(code.replace(/\n$/,'')).split('\n').map(line=>{
|
||
if(line.startsWith('@@')) return `<span class="diff-line diff-hunk">${line}</span>`;
|
||
if(line.startsWith('+')) return `<span class="diff-line diff-plus">${line}</span>`;
|
||
if(line.startsWith('-')) return `<span class="diff-line diff-minus">${line}</span>`;
|
||
return `<span class="diff-line">${line}</span>`;
|
||
}).join('\n');
|
||
_preBlock_stash.push(`${h}<pre class="diff-block"><code${langAttr}>${colored}</code></pre>`);
|
||
// For JSON/YAML blocks, add tree-view placeholder with raw data
|
||
} else if(lang==='json'||lang==='yaml'){
|
||
const rawCode=esc(code.replace(/\n$/,''));
|
||
// Encode newlines as to prevent HTML attribute normalization
|
||
// (browsers collapse \n to spaces inside attribute values).
|
||
const rawAttr=rawCode.replace(/"/g,'"').replace(/\n/g,' ');
|
||
const blockId='tree-'+Math.random().toString(36).slice(2,10);
|
||
_preBlock_stash.push(`<div class="code-tree-wrap" data-raw="${rawAttr}" data-lang="${lang}" id="${blockId}">${h}<pre class="tree-raw-view"><code${langAttr}>${rawCode}</code></pre></div>`);
|
||
// CSV blocks → render as styled table
|
||
} else if(lang==='csv'){
|
||
const rows=code.replace(/\n$/,'').split('\n').filter(r=>r.trim());
|
||
if(rows.length>=2){
|
||
const headers=rows[0].split(',').map(c=>c.trim());
|
||
const body=rows.slice(1).map(r=>'<tr>'+r.split(',').map(c=>`<td>${esc(c.trim())}</td>`).join('')+'</tr>').join('');
|
||
_preBlock_stash.push(`${h}<div class="csv-table-wrap"><table class="csv-table"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></div>`);
|
||
} else {
|
||
_preBlock_stash.push(`${h}<pre><code${langAttr}>${esc(code.replace(/\n$/,''))}</code></pre>`);
|
||
}
|
||
} else {
|
||
_preBlock_stash.push(`${h}<pre><code${langAttr}>${esc(code.replace(/\n$/,''))}</code></pre>`);
|
||
}
|
||
}
|
||
return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00';
|
||
});
|
||
s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push('<code>'+esc(c)+'</code>');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: $$...$$ and \[...\] (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';});
|
||
// Match a single literal backslash before the display delimiter (the common LLM form).
|
||
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)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||
// Also stash \(...\) LaTeX delimiters.
|
||
// Match a single literal backslash before the delimiter (the common LLM form).
|
||
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
|
||
// Stash raw <pre> blocks so the inline <code> rewrite below does not run
|
||
// inside them. Running that rewrite in <pre> content can introduce stray
|
||
// backticks for multiline code and break subsequent code-box rendering.
|
||
const rawPreStash=[];
|
||
s=s.replace(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
|
||
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
|
||
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
|
||
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
|
||
s=s.replace(/<i>([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
|
||
s=s.replace(/<code>([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
|
||
s=s.replace(/<br\s*\/?>/gi,'\n');
|
||
// ── Glued-bold-heading lift (issue #1446) ────────────────────────────────
|
||
// LLMs in thinking/reasoning mode frequently emit a "section header" glued
|
||
// to the end of the previous paragraph with no whitespace, like:
|
||
//
|
||
// Para 1 text.**Heading to Para 2**
|
||
//
|
||
// Para 2 text.**Heading to Para 3**
|
||
//
|
||
// CommonMark renders that correctly as paragraph-end inline bold, but the
|
||
// visual effect is a run-on label rather than a section break. Lift the
|
||
// glued bold into its own paragraph when it follows a sentence terminator
|
||
// and is followed by a blank line.
|
||
//
|
||
// Constraints (avoid false positives):
|
||
// - Trigger only on a sentence terminator (.!?) IMMEDIATELY before `**`
|
||
// (no space) — that pattern is almost always a glued heading, not
|
||
// intentional emphasis.
|
||
// - Inner text length ≤ 80 chars — long bold runs are usually emphasis
|
||
// prose, not headings.
|
||
// - Trailing `\n\n` required — preserves mid-paragraph emphasis like
|
||
// "this is **important**." untouched.
|
||
// - Inner text must not contain newlines or `*` (single-line bold only).
|
||
// - Runs after fenced code, math, and raw <pre> are stashed, so code
|
||
// content is protected (see pipeline notes).
|
||
s=s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g,'$1\n\n**$2**\n\n');
|
||
// Inline backtick spans: restore <code> tags produced in the stash callback above.
|
||
// Must happen BEFORE bold/italic so **`code`** → <strong><code>code</code></strong>.
|
||
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
|
||
// 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(`<code>${esc(x)}</code>`);return `\x00C${_code_stash.length-1}\x00`;});
|
||
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`<strong><em>${esc(x)}</em></strong>`);
|
||
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`<strong>${esc(x)}</strong>`);
|
||
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
|
||
// Strikethrough: ~~text~~ → <del>text</del>
|
||
t=t.replace(/~~(.+?)~~/g,(_,x)=>`<del>${esc(x)}</del>`);
|
||
// #487: Image pass — runs while code stash is active so  inside
|
||
// backticks stays protected as a \x00C token and is never rendered as <img>.
|
||
// 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)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy">`);
|
||
// Stash rendered <img> tags so autolink never matches URLs inside src=
|
||
const _img_stash=[];
|
||
t=t.replace(/(<img\b[^>]*>)/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(`<a href="${u.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(lb)}</a>`);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 `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${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|del|code|a|img)([\s>]|$)/i;
|
||
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
|
||
return t;
|
||
}
|
||
// Stash <code> tags from the backtick pass above so the outer bold/italic
|
||
// regexes don't esc() their content (e.g. **`code`** → <strong><code>code</code></strong>)
|
||
const _ob_stash=[];
|
||
s=s.replace(/(<code\b[^>]*>[\s\S]*?<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;});
|
||
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`<strong><em>${esc(t)}</em></strong>`);
|
||
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`<strong>${esc(t)}</strong>`);
|
||
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`<em>${esc(t)}</em>`);
|
||
s=s.replace(/~~(.+?)~~/g,(_,t)=>`<del>${esc(t)}</del>`);
|
||
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
|
||
s=s.replace(/^###### (.+)$/gm,(_,t)=>`<h6>${inlineMd(t)}</h6>`).replace(/^##### (.+)$/gm,(_,t)=>`<h5>${inlineMd(t)}</h5>`).replace(/^#### (.+)$/gm,(_,t)=>`<h4>${inlineMd(t)}</h4>`).replace(/^### (.+)$/gm,(_,t)=>`<h3>${inlineMd(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${inlineMd(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${inlineMd(t)}</h1>`);
|
||
s=s.replace(/^---+$/gm,'<hr>');
|
||
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
|
||
// fence_stash. The per-line passes below never see > prefixes.)
|
||
// 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='<ul>';
|
||
for(const l of lines){
|
||
const indent=/^ {2,}/.test(l);
|
||
const text=l.replace(/^ {0,4}[-*+] /,'');
|
||
let _ih;
|
||
if(/^\[x\] /i.test(text)) _ih='<span class="task-done">✅</span> '+inlineMd(text.slice(4));
|
||
else if(/^\[ \] /.test(text)) _ih='<span class="task-todo">☐</span> '+inlineMd(text.slice(4));
|
||
else _ih=inlineMd(text);
|
||
if(indent) html+=`<li style="margin-left:16px">${_ih}</li>`;
|
||
else html+=`<li>${_ih}</li>`;
|
||
}
|
||
return html+'</ul>';
|
||
});
|
||
// Ordered lists: use value= on each <li> so the correct number is preserved
|
||
// even when blank lines between items cause the paragraph splitter to place
|
||
// each item in its own <ol> container — without value= every <ol> restarts
|
||
// at 1, producing "1. 1. 1." instead of "1. 2. 3." (#886).
|
||
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
|
||
const lines=block.trimEnd().split('\n');
|
||
let html='<ol>';
|
||
for(const l of lines){
|
||
const numMatch=l.match(/^\s*(\d+)\. /);
|
||
const num=numMatch?parseInt(numMatch[1],10):null;
|
||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||
const valAttr=num!==null?` value="${num}"`:'';
|
||
html+=`<li${valAttr}>${inlineMd(text)}</li>`;
|
||
}
|
||
return html+'</ol>';
|
||
});
|
||
// 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;
|
||
// _protectPipes: temporarily swap pipes inside matching bracket pairs for a
|
||
// sentinel before split('|'), then restore. Iterates until no more matches
|
||
// so all pipes inside one pair are caught.
|
||
// Note: both opening and closing brace literals in the character classes
|
||
// are written as hex escapes (\x7b and \x7d) so the JS source contains no
|
||
// bare brace glyphs that would confuse the brace-counting extractFunc in
|
||
// tests/test_renderer_js_behaviour.py. Regex semantics are identical.
|
||
// Bracket set is paren / square / curly only -- NOT angle brackets, since
|
||
// angle brackets are overwhelmingly comparison operators in real LLM table
|
||
// output (`| x < 5 | y > 10 |`) and treating them as a pair collapses cells.
|
||
const _protectPipes=r=>{let prev;do{prev=r;r=r.replace(/([([\x7b][^)\]\x7d]*)[|]([^)\]\x7d]*[)\]\x7d])/g,(_,a,b)=>a+'\x00PIPE\x00'+b);}while(r!==prev);return r;};
|
||
const _restorePipes=s=>s.replace(/\x00PIPE\x00/g,'|');
|
||
const parseRow=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${inlineMd(_restorePipes(c.trim()))}</td>`).join('');};
|
||
const parseHeader=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(_restorePipes(c.trim()))}</th>`).join('');};
|
||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||
// Surround with blank lines so the final paragraph splitter treats the
|
||
// generated table as its own block even when the regex consumes one of the
|
||
// markdown block's trailing newlines.
|
||
return `\n\n<table><thead>${header}</thead><tbody>${body}</tbody></table>\n\n`;
|
||
});
|
||
// #487: Outer image pass — handles  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)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy">`);
|
||
// 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 <a> tags first to avoid re-linking already-linked URLs.
|
||
const _a_stash=[];
|
||
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
|
||
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${url.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
|
||
// Restore raw <pre> only after markdown rewrites so literal preformatted
|
||
// content stays placeholder-protected, then let the sanitizer normalize tags.
|
||
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
|
||
// Sanitize any remaining HTML tags. The renderer intentionally returns
|
||
// HTML and inserts it with innerHTML later, so tag names alone are not enough:
|
||
// raw/model-provided HTML like <img onerror=...> or <a href="javascript:...">
|
||
// must lose executable attributes and dangerous schemes while preserving the
|
||
// small set of attributes generated by this markdown pipeline.
|
||
// Reference only — documents the allowed tag set. Superseded by _tag() allowlists.
|
||
// Tests verify this list is complete; _tag() enforces it.
|
||
const SAFE_TAGS=/^<\/?(?:strong|em|del|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span|img)([\s>]|$)/i;
|
||
function _safeAttrValue(v){
|
||
return String(v||'').replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,'&').trim();
|
||
}
|
||
function _isSafeUrl(v, img){
|
||
const raw=_safeAttrValue(v);
|
||
const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();
|
||
if(!compact) return false;
|
||
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
|
||
if(/^https?:\/\//i.test(raw)) return true;
|
||
if(img && /^api\//i.test(raw)) return true;
|
||
if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
|
||
return false;
|
||
}
|
||
function _attrs(raw){
|
||
const out={};
|
||
String(raw||'').replace(/([a-zA-Z0-9:_-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g,(_,k,dq,sq,bare)=>{
|
||
out[String(k).toLowerCase()]=dq!==undefined?dq:(sq!==undefined?sq:(bare!==undefined?bare:''));
|
||
return '';
|
||
});
|
||
return out;
|
||
}
|
||
function _cls(v, allowed){
|
||
const got=String(v||'').split(/\s+/).filter(c=>allowed.includes(c));
|
||
return got.length?` class="${esc(got.join(' '))}"`:'';
|
||
}
|
||
function _tag(tag){
|
||
const m=String(tag||'').match(/^<\s*(\/)?\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/)?\s*>$/);
|
||
if(!m) return esc(tag);
|
||
const closing=!!m[1];
|
||
const name=m[2].toLowerCase();
|
||
const rawAttrs=m[3]||'';
|
||
const plain=['strong','em','del','pre','h1','h2','h3','h4','h5','h6','ul','ol','table','thead','tbody','tr','th','td','blockquote','p','br','hr'];
|
||
if(closing) return plain.includes(name)||['a','div','span','li','code'].includes(name)?`</${name}>`:'';
|
||
if(name==='code'){
|
||
const a=_attrs(rawAttrs);
|
||
const cls=/^language-[a-z0-9_+-]+$/i.test(a.class||'')?` class="${esc(a.class)}"`:'';
|
||
return `<code${cls}>`;
|
||
}
|
||
if(plain.includes(name)) return `<${name}>`;
|
||
const a=_attrs(rawAttrs);
|
||
if(name==='li'){
|
||
const value=/^\d+$/.test(a.value||'')?` value="${esc(a.value)}"`:'';
|
||
const style=(a.style||'').replace(/\s+/g,'').toLowerCase()==='margin-left:16px'?` style="margin-left:16px"`:'';
|
||
return `<li${value}${style}>`;
|
||
}
|
||
if(name==='span'){
|
||
return `<span${_cls(a.class,['task-done','task-todo','katex-inline'])}${a['data-katex']==='inline'?' data-katex="inline"':''}>`;
|
||
}
|
||
if(name==='div'){
|
||
const cls=_cls(a.class,['pre-header','mermaid-block','katex-block']);
|
||
const mermaid=a['data-mermaid-id']?` data-mermaid-id="${esc(a['data-mermaid-id'])}"`:'';
|
||
const katex=a['data-katex']==='display'?' data-katex="display"':'';
|
||
return `<div${cls}${mermaid}${katex}>`;
|
||
}
|
||
if(name==='a'){
|
||
if(!_isSafeUrl(a.href,false)) return '<a>';
|
||
const target=a.target==='_blank'?' target="_blank"':'';
|
||
const rel=a.rel==='noopener'?' rel="noopener"':'';
|
||
const cls=_cls(a.class,['msg-media-link','skill-linked-file','skill-file-back']);
|
||
const download=a.download?` download="${esc(a.download)}"`:'';
|
||
return `<a${cls} href="${esc(_safeAttrValue(a.href))}"${target}${rel}${download}>`;
|
||
}
|
||
if(name==='img'){
|
||
if(!_isSafeUrl(a.src,true)) return '';
|
||
const cls=_cls(a.class,['msg-media-img']);
|
||
const alt=` alt="${esc(_safeAttrValue(a.alt||''))}"`;
|
||
const loading=a.loading==='lazy'?' loading="lazy"':'';
|
||
return `<img${cls} src="${esc(_safeAttrValue(a.src))}"${alt}${loading}>`;
|
||
}
|
||
return '';
|
||
}
|
||
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>_tag(tag));
|
||
// Incomplete raw tags must not survive until paragraph wrapping, where the
|
||
// renderer's generated </p> could provide a closing ">" and turn them into
|
||
// executable HTML in innerHTML (for example: <img src=x onerror=...//).
|
||
s=s.replace(/<[a-zA-Z][\w:-]*[^>\n]*$/gm,tag=>esc(tag));
|
||
// Autolink: convert plain URLs to clickable links.
|
||
// Stash <a>, <img> and <pre> blocks so autolink never runs inside them.
|
||
const _al_stash=[];
|
||
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>|<pre\b[^>]*>[\s\S]*?<\/pre>)/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 `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${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 `<div class="katex-block" data-katex="display">${esc(item.src)}</div>`;
|
||
}
|
||
return `<span class="katex-inline" data-katex="inline">${esc(item.src)}</span>`;
|
||
});
|
||
// Restore fenced block stash (\x00P) → <pre><code> HTML.
|
||
// Happens AFTER all markdown passes (lists, headings, tables, etc.) so
|
||
// diff/patch content inside code blocks is never misinterpreted as markdown.
|
||
// The _pre_stash below then protects these blocks from paragraph splitting.
|
||
s=s.replace(/\x00P(\d+)\x00/g,(_,i)=>_preBlock_stash[+i]);
|
||
// Stash rendered <pre> blocks (with optional pre-header div) and mermaid/katex
|
||
// divs before paragraph splitting so \n inside code blocks is never replaced
|
||
// with <br>. Token \x00E (next free after B D F G L M C O A).
|
||
// Fixes #745: code blocks collapse to single line when not preceded by blank line.
|
||
const _pre_stash=[];
|
||
// #1463 / #1618: regex must match <pre> with ANY attributes — PR #484 added
|
||
// <pre class="tree-raw-view"> for JSON/YAML and <pre class="diff-block"> for
|
||
// diff/patch which the literal-<pre> shape missed. Newlines inside those
|
||
// blocks were falling through to the paragraph wrap below and getting
|
||
// converted to <br>, causing the YAML/JSON/diff collapse. PR #1516's CSS
|
||
// fix targeted the wrong layer (Prism token white-space) — by the time it
|
||
// ran, the \n had already been replaced. The CSS rule is kept as defense
|
||
// in depth.
|
||
s=s.replace(/(<div class="pre-header">[\s\S]*?<\/div>)?<pre[^>]*>[\s\S]*?<\/pre>|<div class="(mermaid-block|katex-block)"[\s\S]*?<\/div>/g,m=>{
|
||
_pre_stash.push(m);
|
||
return '\x00E'+(_pre_stash.length-1)+'\x00';
|
||
});
|
||
const parts=s.split(/\n{2,}/);
|
||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
|
||
// ── Restore MEDIA stash → inline images or download links ─────────────────
|
||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
|
||
const ref=media_stash[+i];
|
||
// Keep this logic self-contained: some tests extract renderMd() alone and
|
||
// execute it in node, without the top-level helper functions from ui.js.
|
||
const mediaKindForName=(name='')=>{
|
||
const clean=String(name||'').split('?')[0].toLowerCase();
|
||
if(/\.(mp3|wav|m4a|aac|ogg|oga|opus|flac)$/i.test(clean)) return 'audio';
|
||
if(/\.(mp4|mov|m4v|webm|ogv|avi|mkv)$/i.test(clean)) return 'video';
|
||
if(_IMAGE_EXTS.test(clean)) return 'image';
|
||
return '';
|
||
};
|
||
const mediaPlayerHtml=(kind,src,name)=>{
|
||
if(typeof _mediaPlayerHtml==='function') return _mediaPlayerHtml(kind,src,name);
|
||
const safeName=esc(name||kind||'media');
|
||
const safeSrc=esc(src);
|
||
const tag=kind==='video'
|
||
? `<video class="msg-media-player msg-media-video" src="${safeSrc}" controls preload="metadata" playsinline title="${safeName}"></video>`
|
||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
|
||
};
|
||
// HTTP(S) URL
|
||
if(/^https?:\/\//i.test(ref)){
|
||
// Rewrite localhost/127.0.0.1 to the actual server base URL so remote
|
||
// users (VPN, Docker, deployed) can load agent-generated images (#642).
|
||
// Strip the trailing slash from document.baseURI so the URL's own path
|
||
// joins cleanly — this preserves any subpath mount (e.g. /hermes/).
|
||
let src=ref;
|
||
if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){
|
||
const base=(document.baseURI||'').replace(/\/$/,'');
|
||
src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base);
|
||
}
|
||
// MEDIA: tokens are usually tool-generated images. Render all https://
|
||
// URLs as <img> so extensionless CDN paths still work (#853), while
|
||
// preserving explicit audio/video/SVG URLs with their proper handlers.
|
||
const urlPath=src.split('?')[0];
|
||
const mediaKind=mediaKindForName(urlPath);
|
||
// SVG URLs → render inline as image
|
||
if(_SVG_EXTS.test(urlPath)){
|
||
return `<img class="msg-media-svg" src="${esc(src)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||
}
|
||
if(mediaKind==='audio'||mediaKind==='video') return mediaPlayerHtml(mediaKind,src,urlPath.split('/').pop()||mediaKind);
|
||
// Render all https:// URLs as <img> — extensionless CDN paths like fal.media still work (#853)
|
||
if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){
|
||
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy">`;
|
||
}
|
||
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
|
||
}
|
||
// Local file path
|
||
const apiUrl='api/media?path='+encodeURIComponent(ref);
|
||
const localKind=mediaKindForName(ref);
|
||
if(localKind==='image'){
|
||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
|
||
}
|
||
// SVG → inline image (no download, render directly)
|
||
if(_SVG_EXTS.test(ref)){
|
||
return `<img class="msg-media-svg" src="${esc(apiUrl)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||
}
|
||
// Audio/video → inline player with speed controls; use &inline=1 for byte-range seeking
|
||
if(_AUDIO_EXTS.test(ref)||_VIDEO_EXTS.test(ref)){
|
||
const kind=_AUDIO_EXTS.test(ref)?'audio':'video';
|
||
return _mediaPlayerHtml(kind,apiUrl+'&inline=1',ref.split('/').pop()||ref);
|
||
}
|
||
// PDF files → render first page preview with lazy-load
|
||
if(_PDF_EXTS.test(ref)){
|
||
const fname=esc(ref.split('/').pop()||ref);
|
||
return `<div class="pdf-preview-load" data-path="${esc(ref)}"><span class="pdf-preview-spinner">⏳</span> ${t('pdf_loading')} ${fname}...</div>`;
|
||
}
|
||
// HTML files → render inline in sandboxed iframe with lazy-load
|
||
if(_HTML_EXTS.test(ref)){
|
||
return `<div class="html-preview-load" data-path="${esc(ref)}"><span class="html-preview-spinner">⏳</span> ${t('html_loading')}</div>`;
|
||
}
|
||
// .patch/.diff files → render inline as colored diff instead of download
|
||
const fname=esc(ref.split('/').pop()||ref);
|
||
if(/\.(patch|diff)$/i.test(ref)){
|
||
return `<div class="diff-inline-load" data-path="${esc(ref)}">${t('diff_loading')} ${fname}...</div>`;
|
||
}
|
||
// CSV files → lazy-load and render as table
|
||
if(_CSV_EXTS.test(ref)){
|
||
return `<div class="csv-inline-load" data-path="${esc(ref)}">${t('csv_loading')} ${fname}...</div>`;
|
||
}
|
||
// Excalidraw files → lazy-load inline embed
|
||
if(_EXCALIDRAW_EXTS.test(ref)){
|
||
return `<div class="excalidraw-inline-load" data-path="${esc(ref)}">${t('excalidraw_loading')} ${fname}...</div>`;
|
||
}
|
||
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
|
||
});
|
||
|
||
// ── End MEDIA restore ──────────────────────────────────────────────────────
|
||
// Restore blockquote stash. Done last so the inner HTML (already produced
|
||
// by the recursive renderMd in the pre-pass) is dropped into the final
|
||
// string verbatim — no further passes can mangle it.
|
||
s=s.replace(/\x00Q(\d+)\x00/g,(_,i)=>_bq_stash[+i]);
|
||
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;
|
||
// Save the current composer text as a server-side draft before locking,
|
||
// so the user's draft is preserved if they switch sessions while a clarify
|
||
// card is active (and survives page refresh / syncs across clients).
|
||
const sid = S && S.session && S.session.session_id;
|
||
if (sid && typeof _saveComposerDraftNow === 'function') {
|
||
_saveComposerDraftNow(sid, input.value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||
}
|
||
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 _composerHasContent(){
|
||
const msg=$('msg');
|
||
return !!((msg&&msg.value.trim().length>0)||S.pendingFiles.length>0);
|
||
}
|
||
|
||
function _getExplicitBusyCommandAction(text){
|
||
const trimmed=(text||'').trim();
|
||
if(!trimmed.startsWith('/')) return null;
|
||
const body=trimmed.slice(1);
|
||
const name=(body.split(/\s+/)[0]||'').toLowerCase();
|
||
const args=body.slice(name.length).trim();
|
||
if(!args) return null;
|
||
if(name==='queue') return 'queue';
|
||
if(name==='steer'){
|
||
if(S.activeStreamId&&typeof _trySteer==='function') return 'steer';
|
||
return 'queue';
|
||
}
|
||
if(name==='interrupt'){
|
||
if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt';
|
||
return 'queue';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getComposerPrimaryAction(){
|
||
const msg=$('msg');
|
||
const hasContent=_composerHasContent();
|
||
const locked=!!(msg&&msg.disabled);
|
||
if(locked) return 'disabled';
|
||
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
|
||
const isBusy=!!S.busy||compressionRunning;
|
||
if(!isBusy) return hasContent?'send':'disabled';
|
||
if(!hasContent){
|
||
if(S.activeStreamId&&typeof cancelStream==='function') return 'stop';
|
||
return 'disabled';
|
||
}
|
||
const explicitAction=_getExplicitBusyCommandAction(msg&&msg.value);
|
||
if(explicitAction) return explicitAction;
|
||
const busyMode=window._busyInputMode||'queue';
|
||
if(busyMode==='steer'){
|
||
if(S.activeStreamId&&typeof _trySteer==='function') return 'steer';
|
||
return 'queue';
|
||
}
|
||
if(busyMode==='interrupt'){
|
||
if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt';
|
||
return 'queue';
|
||
}
|
||
return 'queue';
|
||
}
|
||
|
||
function _setComposerPrimaryButtonIcon(btn,action){
|
||
// Queue/interrupt/steer icons are inline Lucide SVGs (ISC):
|
||
// https://lucide.dev/icons/
|
||
const icons={
|
||
send:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
|
||
queue:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 5H3"/><path d="M16 12H3"/><path d="M9 19H3"/><path d="m16 16-3 3 3 3"/><path d="M21 5v12a2 2 0 0 1-2 2h-6"/></svg>',
|
||
interrupt:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4v16"/><path d="M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z"/></svg>',
|
||
steer:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/></svg>',
|
||
stop:'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>',
|
||
disabled:'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>'
|
||
};
|
||
const next=icons[action]||icons.send;
|
||
if(btn.innerHTML!==next) btn.innerHTML=next;
|
||
}
|
||
|
||
function updateSendBtn(){
|
||
const btn=$('btnSend');
|
||
if(!btn) return;
|
||
const action=getComposerPrimaryAction();
|
||
btn.dataset.action=action;
|
||
btn.classList.toggle('stop',action==='stop');
|
||
btn.classList.toggle('queue',action==='queue');
|
||
btn.classList.toggle('interrupt',action==='interrupt');
|
||
btn.classList.toggle('steer',action==='steer');
|
||
const _tt=(key,fb)=>{if(typeof t!=='function')return fb;const val=t(key);return val===key?fb:(val||fb);};
|
||
let _btnTitle;
|
||
if(action==='disabled'){
|
||
const _dmsg=$('msg');
|
||
const _dcompr=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
|
||
if(_dmsg&&_dmsg.disabled) _btnTitle=_tt('composer_disabled_clarify','Respond to the clarification request');
|
||
else if(_dcompr) _btnTitle=_tt('composer_disabled_compression','Waiting for compression to finish');
|
||
else _btnTitle=_tt('composer_disabled_empty','Type a message to send');
|
||
}else{
|
||
const _tmap={send:'Send message',queue:'Queue message',interrupt:'Interrupt and send',steer:'Steer current response',stop:'Stop generation'};
|
||
_btnTitle=_tt('composer_'+action,_tmap[action]||'Send message');
|
||
}
|
||
btn.title=_btnTitle;
|
||
btn.setAttribute('aria-label',_btnTitle);
|
||
_setComposerPrimaryButtonIcon(btn,action);
|
||
// Single primary action button: while busy/no-draft it becomes the red Stop
|
||
// action; while busy with a draft it reflects queue/interrupt/steer.
|
||
btn.style.display='';
|
||
btn.disabled=action==='disabled';
|
||
if(action!=='disabled'&&!btn.classList.contains('visible')){
|
||
btn.classList.remove('visible');
|
||
requestAnimationFrame(()=>btn.classList.add('visible'));
|
||
} else if(action==='disabled'){
|
||
btn.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
async function handleComposerPrimaryAction(){
|
||
if(window._micActive){
|
||
window._micPendingSend=true;
|
||
_stopMic();
|
||
return;
|
||
}
|
||
const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send';
|
||
if(action==='disabled') return;
|
||
if(action==='stop'){
|
||
if(typeof cancelStream==='function') await cancelStream();
|
||
return;
|
||
}
|
||
await send();
|
||
}
|
||
|
||
function setBusy(v){
|
||
S.busy=v;
|
||
updateSendBtn();
|
||
if(!v){
|
||
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
|
||
setStatus('');
|
||
setComposerStatus('');
|
||
const sid=_queueDrainSid||(S.session&&S.session.session_id);
|
||
_queueDrainSid=null;
|
||
updateQueueBadge(sid);
|
||
// Drain one queued message for the finished session after UI settles
|
||
const _isViewedSid=!S.session||sid===S.session.session_id;
|
||
const next=sid&&_isViewedSid?shiftQueuedSessionMessage(sid):null;
|
||
if(next){
|
||
updateQueueBadge(sid);
|
||
setTimeout(()=>{
|
||
$('msg').value=next.text||'';
|
||
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
|
||
// Restore model from queued item (sent in /api/chat/start payload)
|
||
// Note: profile is NOT restored — full profile switch requires server interaction
|
||
if(next.model&&S.session&&next.model!==S.session.model){
|
||
S.session.model=next.model;
|
||
}
|
||
if(next.model_provider&&S.session) S.session.model_provider=next.model_provider;
|
||
if(next.model&&S.session){
|
||
if(typeof _applyModelToDropdown==='function'&&$('modelSelect')) _applyModelToDropdown(next.model,$('modelSelect'),S.session.model_provider||null);
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
}
|
||
autoResize();
|
||
renderTray();
|
||
send();
|
||
},120);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Queue chip display (Codex Desktop pattern) ─────────────────────────────
|
||
// Queued messages appear as chips inside #queueChips (above the textarea)
|
||
// while pending. When the session fires the queued message it becomes a
|
||
// normal user bubble in the chat — the chip is removed at drain time.
|
||
const _queueRenderKeys={}; // per-session fingerprint to avoid redundant rebuilds
|
||
const _queueCollapsed={}; // per-session: true when user explicitly collapsed the card
|
||
|
||
function _renderQueueChips(sid){
|
||
const card=document.getElementById('queueCard');
|
||
const inner=document.getElementById('queueChips');
|
||
if(!card||!inner) return;
|
||
const q=_getSessionQueue(sid,false);
|
||
const key=q.map(e=>{const t=e&&(e.text||e.message||e.content||'');return(e&&e._queued_at||0)+':'+t.length+':'+t.slice(0,20);}).join('|');
|
||
if(key===(_queueRenderKeys[sid]||'')&&key!='') return;
|
||
// Skip re-render if user is actively editing inside the queue panel
|
||
if(inner.contains(document.activeElement)&&document.activeElement!==inner) return;
|
||
_queueRenderKeys[sid]=key;
|
||
inner.innerHTML='';
|
||
if(!q.length){
|
||
card.classList.remove('visible');
|
||
const _msgs=document.getElementById('messages');
|
||
if(_msgs) _msgs.classList.remove('queue-open');
|
||
return;
|
||
}
|
||
// Respect user-collapsed state — don't reopen if user explicitly hid the card
|
||
if(_queueCollapsed[sid]){
|
||
// Update chips content without showing card (so data is fresh if user re-expands)
|
||
inner.innerHTML='';
|
||
// fall through to render rows into inner but skip making card visible
|
||
} else {
|
||
card.classList.add('visible');
|
||
}
|
||
// Push messages area up so content isn't hidden behind the flyout
|
||
const _msgs=document.getElementById('messages');
|
||
if(_msgs&&!_queueCollapsed[sid]){
|
||
_msgs.classList.add('queue-open');
|
||
// Measure after 350ms transition completes (not mid-animation — height would be wrong)
|
||
setTimeout(()=>{
|
||
if(!card.classList.contains('visible')) return;
|
||
const h=card.getBoundingClientRect().height;
|
||
if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px');
|
||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||
}, 360);
|
||
}
|
||
|
||
function _saveAndRefresh(){
|
||
const liveQ=_getSessionQueue(sid,false);
|
||
if(!liveQ.length){delete SESSION_QUEUES[sid];try{sessionStorage.removeItem('hermes-queue-'+sid);}catch(_){}}
|
||
else{SESSION_QUEUES[sid]=[...liveQ];try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}}
|
||
delete _queueRenderKeys[sid];
|
||
updateQueueBadge(sid);
|
||
}
|
||
|
||
// Header (2+ items)
|
||
if(q.length>1){
|
||
const header=document.createElement('div');
|
||
header.className='queue-card-header';
|
||
const lbl=document.createElement('span');
|
||
lbl.textContent=typeof t==='function'?t('queued_count',q.length):(q.length===1?'1 queued':`${q.length} queued`);
|
||
lbl.title='Sends automatically after the current response completes';
|
||
const actions=document.createElement('span');
|
||
actions.className='queue-card-header-actions';
|
||
const hasFiles=q.some(e=>e&&Array.isArray(e.files)&&e.files.length>0);
|
||
const mergeBtn=document.createElement('button');
|
||
mergeBtn.className='queue-card-btn';
|
||
mergeBtn.title='Combine all into one message'+(hasFiles?' — attachments will be removed':'');
|
||
mergeBtn.innerHTML=li('layers',12)+'Combine';
|
||
mergeBtn.onclick=()=>{
|
||
const _doMerge=(snapshot)=>{
|
||
const combined=snapshot.map(e=>e&&(e.text||e.message||e.content||'')).filter(Boolean).join('\n\n');
|
||
const liveQ=_getSessionQueue(sid,false);
|
||
const first=snapshot.find(e=>e)||{};
|
||
const firstFiles=(snapshot.find(e=>e&&Array.isArray(e.files)&&e.files.length)||{files:[]}).files;
|
||
liveQ.length=0;liveQ.push({text:combined,files:firstFiles,model:first.model||'',model_provider:first.model_provider||null,_queued_at:Date.now()});
|
||
SESSION_QUEUES[sid]=liveQ;
|
||
try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}
|
||
delete _queueRenderKeys[sid];
|
||
updateQueueBadge(sid);
|
||
};
|
||
if(hasFiles){
|
||
if(typeof showToast==='function') showToast('Attachments on queued items will be removed',2600,'warning');
|
||
}
|
||
// Merge from current live queue (no delay — snapshot + defer caused data-loss races)
|
||
_doMerge([..._getSessionQueue(sid,false)]);
|
||
};
|
||
const clearBtn=document.createElement('button');
|
||
clearBtn.className='queue-card-icon-btn';
|
||
clearBtn.title='Clear all queued messages';
|
||
clearBtn.setAttribute('aria-label','Clear all queued messages');
|
||
clearBtn.innerHTML=li('x',13);
|
||
clearBtn.onclick=()=>{q.length=0;_saveAndRefresh();};
|
||
actions.appendChild(mergeBtn);
|
||
actions.appendChild(clearBtn);
|
||
// Hide button — collapses flyout entirely; queue pill re-shows it
|
||
const hideBtn=document.createElement('button');
|
||
hideBtn.className='queue-card-icon-btn';
|
||
hideBtn.title='Hide queue (click the queue pill to show again)';
|
||
hideBtn.setAttribute('aria-label','Hide queue panel');
|
||
hideBtn.innerHTML=li('chevron-down',14);
|
||
hideBtn.onclick=()=>{
|
||
_queueCollapsed[sid]=true;
|
||
card.classList.remove('visible');
|
||
// Read live count at click time (not stale closure q)
|
||
_updateQueuePill(sid,_getSessionQueue(sid,false).length);
|
||
};
|
||
actions.appendChild(hideBtn);
|
||
header.appendChild(lbl);
|
||
header.appendChild(actions);
|
||
inner.appendChild(header);
|
||
}
|
||
|
||
let _dragTs=null; // use _queued_at timestamp — survives re-renders, not an index
|
||
q.forEach((entry,i)=>{
|
||
const _entryTs=entry&&entry._queued_at;
|
||
const entryText=entry&&(entry.text||entry.message||entry.content||'');
|
||
const _files=entry&&Array.isArray(entry.files)?entry.files.filter(Boolean):[];
|
||
const row=document.createElement('div');
|
||
row.className='queue-card-row';
|
||
row.setAttribute('role','listitem');
|
||
row.setAttribute('draggable','true');
|
||
row.ondragstart=(e)=>{if(_entryTs==null) return;_dragTs=_entryTs;row.style.opacity='.4';e.dataTransfer.effectAllowed='move';};
|
||
row.ondragend=()=>{row.style.opacity='';};
|
||
row.ondragover=(e)=>{e.preventDefault();row.style.background='var(--hover-bg)';};
|
||
row.ondragleave=()=>{row.style.background='';};
|
||
row.ondrop=(e)=>{
|
||
e.preventDefault();row.style.background='';
|
||
if(_dragTs!=null&&_dragTs!==_entryTs){
|
||
const fromIdx=q.findIndex(e=>e&&e._queued_at===_dragTs);
|
||
if(fromIdx!==-1&&fromIdx!==i){const moved=q.splice(fromIdx,1)[0];q.splice(i,0,moved);}
|
||
_dragTs=null;_saveAndRefresh();
|
||
}
|
||
};
|
||
// Drag handle
|
||
const drag=document.createElement('span');
|
||
drag.className='queue-card-drag';
|
||
drag.setAttribute('aria-hidden','true');
|
||
drag.innerHTML=typeof li==='function'?li('list-todo',13):'≡';
|
||
// Inline-editable text
|
||
const msgSpan=document.createElement('span');
|
||
msgSpan.className='queue-card-text';
|
||
msgSpan.setAttribute('contenteditable','true');
|
||
msgSpan.setAttribute('role','textbox');
|
||
msgSpan.setAttribute('aria-label','Queued message — edit in place');
|
||
msgSpan.textContent=entryText||(_files.length?'':'—');
|
||
msgSpan.setAttribute('draggable','false');
|
||
msgSpan.onfocus=()=>{msgSpan.style.overflow='auto';msgSpan.style.whiteSpace='pre-wrap';msgSpan.style.textOverflow='clip';};
|
||
msgSpan.onblur=()=>{
|
||
msgSpan.style.overflow='';msgSpan.style.whiteSpace='';msgSpan.style.textOverflow='';
|
||
const newText=msgSpan.textContent.trim();
|
||
if(newText===''&&!_files.length){ msgSpan.textContent=entryText||'—'; return; }
|
||
if(newText!==entryText){
|
||
const liveQ=_getSessionQueue(sid,false);
|
||
const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i;
|
||
if(idx!==-1){
|
||
liveQ[idx]={...liveQ[idx],text:newText};
|
||
try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}
|
||
delete _queueRenderKeys[sid];
|
||
updateQueueBadge(sid);
|
||
}
|
||
}
|
||
};
|
||
msgSpan.onkeydown=(e)=>{if(e.key==='Enter'){e.preventDefault();msgSpan.blur();}if(e.key==='Escape'){msgSpan.textContent=entryText||'—';msgSpan.blur();}};
|
||
// Compact badges (files, model, profile)
|
||
const badges=document.createElement('span');
|
||
badges.className='queue-card-badges';
|
||
if(_files.length>0){
|
||
const fb=document.createElement('span');
|
||
fb.className='queue-card-file-badge';
|
||
fb.title=_files.map(f=>f&&f.name||'file').join(', ');
|
||
fb.innerHTML=li('paperclip',11)+_files.length;
|
||
badges.appendChild(fb);
|
||
}
|
||
const _model=entry&&entry.model;
|
||
if(_model){
|
||
const mb=document.createElement('span');
|
||
mb.title='Model: '+_model;
|
||
// Use the app's friendly label system if available
|
||
const _modelLabel=(typeof _dynamicModelLabels!=='undefined'&&_dynamicModelLabels[_model])
|
||
||_model.split('/').pop().replace(/^(gpt-|claude-3\.?5?-|claude-|gemini-)/,'').replace(/-\d{4}-\d{2}-\d{2}$/,'').slice(0,12);
|
||
mb.textContent=_modelLabel;
|
||
badges.appendChild(mb);
|
||
}
|
||
// Profile badge removed — drain cannot server-switch profiles so badge was misleading
|
||
// Delete button
|
||
const delBtn=document.createElement('button');
|
||
delBtn.className='queue-card-icon-btn';
|
||
delBtn.setAttribute('aria-label',typeof t==='function'?t('queued_cancel'):'Remove queued message');
|
||
delBtn.setAttribute('draggable','false');
|
||
delBtn.title='Remove from queue';
|
||
delBtn.innerHTML=li('x',13);
|
||
delBtn.onclick=()=>{
|
||
const liveQ=_getSessionQueue(sid,false);
|
||
const idx=_entryTs!=null?liveQ.findIndex(e=>e&&e._queued_at===_entryTs):i;
|
||
if(idx!==-1) liveQ.splice(idx,1);
|
||
if(!liveQ.length){delete SESSION_QUEUES[sid];try{sessionStorage.removeItem('hermes-queue-'+sid);}catch(_){}}
|
||
else{SESSION_QUEUES[sid]=[...liveQ];try{sessionStorage.setItem('hermes-queue-'+sid,JSON.stringify(liveQ));}catch(_){}}
|
||
delete _queueRenderKeys[sid];
|
||
updateQueueBadge(sid);
|
||
};
|
||
row.appendChild(drag);
|
||
row.appendChild(msgSpan);
|
||
if(badges.childNodes.length) row.appendChild(badges);
|
||
row.appendChild(delBtn);
|
||
inner.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function _updateQueuePill(sid,count){
|
||
const pill=document.getElementById('queuePill');
|
||
if(!pill) return;
|
||
const pillOuter=pill.parentElement; // .queue-pill-outer — same wrapper as .queue-card
|
||
const card=document.getElementById('queueCard');
|
||
const flyoutVisible=card&&card.classList.contains('visible');
|
||
if(count>0&&!flyoutVisible){
|
||
const label=typeof t==='function'?t('queued_count',count):(count===1?'1 queued':`${count} queued`);
|
||
pill.innerHTML=(typeof li==='function'?li('list-todo',12):'')+
|
||
`<span class="queue-pill-count">${label}</span>`+
|
||
`<span class="queue-pill-chevron">`+(typeof li==='function'?li('chevron-up',12):'▲')+`</span>`;
|
||
pill.title='Show queued messages';
|
||
if(pillOuter) pillOuter.classList.add('show');
|
||
pill.onclick=()=>{
|
||
delete _queueCollapsed[sid];
|
||
const c=document.getElementById('queueCard');
|
||
if(c){
|
||
c.classList.add('visible');
|
||
setTimeout(()=>{
|
||
const firstFocusable=c.querySelector('.queue-card-text, .queue-card-icon-btn');
|
||
if(firstFocusable) firstFocusable.focus();
|
||
}, 360);
|
||
}
|
||
if(pillOuter) pillOuter.classList.remove('show');
|
||
if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned();
|
||
else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom();
|
||
};
|
||
} else {
|
||
if(pillOuter) pillOuter.classList.remove('show');
|
||
pill.onclick=null;
|
||
}
|
||
}
|
||
|
||
function updateQueueBadge(sessionId){
|
||
const sid=sessionId||(S.session&&S.session.session_id);
|
||
const count=sid?getQueuedSessionCount(sid):0;
|
||
if(count>0&&S.session&&sid===S.session.session_id){
|
||
_renderQueueChips(sid);
|
||
// If card is visible, hide pill. If card is collapsed, update pill count.
|
||
const _cardEl=document.getElementById('queueCard');
|
||
_updateQueuePill(sid,(_cardEl&&_cardEl.classList.contains('visible'))?0:count);
|
||
} else {
|
||
// Always clean up per-session data
|
||
if(sid){delete _queueRenderKeys[sid];delete _queueCollapsed[sid];}
|
||
// Only wipe global DOM if this is the currently active session
|
||
const isActive=S.session&&sid===S.session.session_id;
|
||
if(isActive){
|
||
const card=document.getElementById('queueCard');
|
||
const chips=document.getElementById('queueChips');
|
||
if(card) card.classList.remove('visible');
|
||
// Defer clear until after slide-out transition so content doesn't vanish mid-animation
|
||
if(chips){const _chips=chips;const _card=card;setTimeout(()=>{if(!_card||!_card.classList.contains('visible'))_chips.innerHTML='';},360);}
|
||
const _msgsEl=document.getElementById('messages');
|
||
if(_msgsEl) _msgsEl.classList.remove('queue-open');
|
||
_updateQueuePill(sid,0);
|
||
}
|
||
}
|
||
}
|
||
const TOAST_DEFAULT_MS=2800;
|
||
const TOAST_ERROR_DEFAULT_MS=20000;
|
||
function clearToastDismissTimer(el){if(!el)return;clearTimeout(el._t);el._t=null;}
|
||
function setToastDismissTimer(el,duration){if(!el)return;clearToastDismissTimer(el);el._t=setTimeout(()=>{el.classList.remove('show');},duration);}
|
||
function copyToastText(btn){
|
||
const el=btn&&btn.closest?btn.closest('#toast'):null;
|
||
const text=el?(el.dataset.toastMessage||el.textContent||''):'';
|
||
const done=()=>{const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>{btn.textContent=old;},1200);};
|
||
_copyText(text).then(done).catch(()=>{});
|
||
}
|
||
function showToast(msg,ms,type){
|
||
const el=$('toast');if(!el)return;
|
||
const s=String(msg==null?'':msg);let t=type;
|
||
if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';}
|
||
const duration=(ms==null)?(t==='error'?TOAST_ERROR_DEFAULT_MS:TOAST_DEFAULT_MS):ms;
|
||
el.className='toast show '+t;
|
||
el.dataset.toastMessage=s;
|
||
if(t==='error') el.innerHTML=`<span class="toast-message">${esc(s)}</span><button class="toast-copy" type="button" data-toast-copy="1" onclick="copyToastText(this);event.stopPropagation()">Copy</button>`;
|
||
else el.textContent=s;
|
||
el.onmouseenter=()=>clearToastDismissTimer(el);
|
||
el.onmouseleave=()=>setToastDismissTimer(el,duration);
|
||
el.onfocusin=()=>clearToastDismissTimer(el);
|
||
el.onfocusout=()=>setToastDismissTimer(el,duration);
|
||
setToastDismissTimer(el,duration);
|
||
}
|
||
|
||
// ── 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'){
|
||
if(window._isImeEnter&&window._isImeEnter(e)) return;
|
||
const target=e.target;
|
||
const isTextarea=target&&target.tagName==='TEXTAREA';
|
||
if(!isTextarea){
|
||
e.preventDefault();
|
||
if(target===cancelBtn||target===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 idx=nodes.indexOf(document.activeElement);
|
||
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={}){
|
||
_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;
|
||
if(title) title.textContent=opts.title||t('dialog_confirm_title');
|
||
if(desc) desc.textContent=opts.message||'';
|
||
if(input){input.style.display='none';input.value='';}
|
||
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
||
if(confirmBtn){
|
||
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
|
||
confirmBtn.classList.toggle('danger',!!opts.danger);
|
||
}
|
||
if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog');
|
||
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
|
||
return new Promise(resolve=>{
|
||
APP_DIALOG.resolve=resolve;
|
||
setTimeout(()=>((opts.focusCancel?cancelBtn:confirmBtn)||confirmBtn||cancelBtn).focus(),0);
|
||
});
|
||
}
|
||
|
||
function showPromptDialog(opts={}){
|
||
_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;
|
||
if(title) title.textContent=opts.title||t('dialog_prompt_title');
|
||
if(desc) desc.textContent=opts.message||'';
|
||
if(input){
|
||
input.type=opts.inputType||'text';input.style.display='';
|
||
// Pre-fill: prefer `value`, accept `defaultValue` as alias for callers that
|
||
// mirror the standard HTMLInputElement.defaultValue naming. Both empty →
|
||
// blank field (the default rename-from-scratch flow stays unchanged).
|
||
const prefill=(opts.value!=null?opts.value:(opts.defaultValue!=null?opts.defaultValue:''));
|
||
input.value=prefill;input.placeholder=opts.placeholder||'';
|
||
input.autocomplete='off';input.spellcheck=false;
|
||
}
|
||
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
||
if(confirmBtn){confirmBtn.textContent=opts.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();
|
||
// Selection behavior on focus:
|
||
// selectStem:true → select everything before the LAST '.' (e.g. for
|
||
// 'report.txt' selects 'report' so a user can retype the basename
|
||
// without losing the extension; matches macOS Finder rename UX).
|
||
// Falls back to selecting the full value when there's no '.' or
|
||
// the dot is at index 0 ('.gitignore' → full select).
|
||
// selectAll:true → select the entire prefilled value.
|
||
// default → caret at end (current behavior).
|
||
const v=input.value||'';
|
||
if(opts.selectStem && v){
|
||
const dot=v.lastIndexOf('.');
|
||
if(dot>0) input.setSelectionRange(0,dot);
|
||
else input.select();
|
||
} else if(opts.selectAll && v){
|
||
input.select();
|
||
}
|
||
} else if(confirmBtn) confirmBtn.focus();
|
||
},0);
|
||
});
|
||
}
|
||
|
||
|
||
function _copyText(text){
|
||
if(navigator.clipboard && window.isSecureContext){
|
||
return navigator.clipboard.writeText(text).catch(()=>{
|
||
// Fallback if clipboard API fails (e.g. permissions)
|
||
return _fallbackCopy(text);
|
||
});
|
||
}
|
||
return _fallbackCopy(text);
|
||
}
|
||
function _fallbackCopy(text){
|
||
return new Promise((resolve,reject)=>{
|
||
const ta=document.createElement('textarea');
|
||
ta.value=text;ta.style.cssText='position:fixed;left:0;top:0;width:2em;height:2em;padding:0;border:none;outline:none;box-shadow:none;background:transparent;z-index:-1';
|
||
document.body.appendChild(ta);
|
||
ta.focus();ta.select();
|
||
try{document.execCommand('copy');resolve();}
|
||
catch(e){reject(e);}
|
||
finally{document.body.removeChild(ta);}
|
||
});
|
||
}
|
||
function copyStatusSessionId(btn){
|
||
const text=btn&&btn.getAttribute('data-copy-status-session');
|
||
if(!text)return;
|
||
_copyText(text).then(()=>{
|
||
const orig=btn.innerHTML;
|
||
btn.innerHTML=(typeof li==='function')?li('check',13):t('copied');
|
||
btn.classList.add('copied');
|
||
setTimeout(()=>{btn.innerHTML=orig;btn.classList.remove('copied');},1500);
|
||
}).catch(()=>showToast(t('copy_failed')));
|
||
}
|
||
function copyMsg(btn){
|
||
const row=btn.closest('[data-raw-text]');
|
||
const text=row?row.dataset.rawText:'';
|
||
if(!text)return;
|
||
_copyText(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(t('copy_failed')));
|
||
}
|
||
function _copyThinkingText(btn){
|
||
const card=btn&&btn.closest?btn.closest('.thinking-card'):null;
|
||
if(!card)return;
|
||
const pre=card.querySelector('.thinking-card-body pre');
|
||
const text=pre?pre.textContent:'';
|
||
if(!text)return;
|
||
_copyText(text).then(()=>{
|
||
const orig=btn.innerHTML;
|
||
btn.innerHTML=li('check',12);
|
||
btn.style.color='var(--accent)';
|
||
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
|
||
}).catch(()=>showToast(t('copy_failed')));
|
||
}
|
||
|
||
// ── TTS: Text-to-Speech via Web Speech API (#499) ──
|
||
// Strips markdown, code blocks, and MEDIA: paths for clean speech output.
|
||
function _stripForTTS(text){
|
||
// Remove code blocks entirely (```) — line-anchored to match #1438 fix
|
||
text=text.replace(/(^|\n)[ ]{0,3}```(?:[\s\S]*?\n)?[ ]{0,3}```(?=\n|$)/g,' ');
|
||
// Remove inline code
|
||
text=text.replace(/`[^`]+`/g,' ');
|
||
// Strip bold/italic
|
||
text=text.replace(/\*\*(.+?)\*\*/g,'$1');
|
||
text=text.replace(/\*(.+?)\*/g,'$1');
|
||
text=text.replace(/__(.+?)__/g,'$1');
|
||
text=text.replace(/_(.+?)_/g,'$1');
|
||
// Strip headings
|
||
text=text.replace(/^#{1,6}\s+/gm,'');
|
||
// Strip links, keep text
|
||
text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
|
||
// Replace MEDIA: paths with a simple label
|
||
text=text.replace(/MEDIA:[^\s]+/g,'a file');
|
||
// Strip HTML tags that may leak through markdown
|
||
text=text.replace(/<[^>]+>/g,' ');
|
||
// Collapse whitespace
|
||
text=text.replace(/\s+/g,' ').trim();
|
||
return text;
|
||
}
|
||
|
||
let _ttsSpeaking=false;
|
||
let _ttsCurrentUtterance=null;
|
||
|
||
function speakMessage(btn){
|
||
if(!('speechSynthesis' in window)){
|
||
showToast(t('tts_not_supported')||'Speech synthesis not supported in this browser.');
|
||
return;
|
||
}
|
||
// If already speaking this message, stop
|
||
if(btn&&btn.dataset.speaking==='1'){
|
||
stopTTS();
|
||
return;
|
||
}
|
||
// Stop any current speech
|
||
stopTTS();
|
||
|
||
const row=btn?btn.closest('[data-raw-text]'):null;
|
||
const text=row?row.dataset.rawText:'';
|
||
if(!text) return;
|
||
|
||
const clean=_stripForTTS(text);
|
||
if(!clean) return;
|
||
|
||
const utter=new SpeechSynthesisUtterance(clean);
|
||
|
||
// Apply saved voice preference
|
||
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||
const voices=speechSynthesis.getVoices();
|
||
if(savedVoice&&voices.length){
|
||
const match=voices.find(v=>v.name===savedVoice);
|
||
if(match) utter.voice=match;
|
||
}
|
||
|
||
// Apply saved rate/pitch
|
||
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
|
||
if(!isNaN(savedRate)) utter.rate= Math.min(2,Math.max(0.5,savedRate));
|
||
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
|
||
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
|
||
|
||
_ttsCurrentUtterance=utter;
|
||
_ttsSpeaking=true;
|
||
if(btn) btn.dataset.speaking='1';
|
||
|
||
utter.onend=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
|
||
utter.onerror=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
|
||
|
||
speechSynthesis.speak(utter);
|
||
}
|
||
|
||
function stopTTS(){
|
||
if('speechSynthesis' in window){
|
||
speechSynthesis.cancel();
|
||
}
|
||
_ttsSpeaking=false;
|
||
_ttsCurrentUtterance=null;
|
||
// Reset all speaking buttons
|
||
document.querySelectorAll('[data-speaking="1"]').forEach(btn=>{ btn.dataset.speaking='0'; });
|
||
}
|
||
|
||
function autoReadLastAssistant(){
|
||
if(!('speechSynthesis' in window)) return;
|
||
const pref=localStorage.getItem('hermes-tts-auto-read');
|
||
if(pref!=='true') return;
|
||
// Find the last assistant message segment in the DOM
|
||
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||
if(!rows.length) return;
|
||
const last=rows[rows.length-1];
|
||
const text=last.dataset.rawText||'';
|
||
if(!text.trim()) return;
|
||
const clean=_stripForTTS(text);
|
||
if(!clean) return;
|
||
|
||
const utter=new SpeechSynthesisUtterance(clean);
|
||
const savedVoice=localStorage.getItem('hermes-tts-voice');
|
||
const voices=speechSynthesis.getVoices();
|
||
if(savedVoice&&voices.length){
|
||
const match=voices.find(v=>v.name===savedVoice);
|
||
if(match) utter.voice=match;
|
||
}
|
||
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
|
||
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
|
||
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
|
||
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
|
||
|
||
speechSynthesis.speak(utter);
|
||
}
|
||
|
||
// ── Reconnect banner (B4/B5: reload resilience) ──
|
||
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
|
||
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
|
||
|
||
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()};
|
||
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 snapshotLiveTurnHtmlForSession(sid){
|
||
// Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally
|
||
// stores structured stream state, not outerHTML, so a hard reload still uses
|
||
// the safer flat replay path instead of reviving stale nodes/listeners.
|
||
if(!sid||!INFLIGHT[sid]) return;
|
||
const turn=$('liveAssistantTurn');
|
||
if(!turn) return;
|
||
if(turn.dataset&&turn.dataset.sessionId&&turn.dataset.sessionId!==sid) return;
|
||
INFLIGHT[sid].liveTurnHtml=turn.outerHTML;
|
||
}
|
||
|
||
function _liveAssistantSegmentTextLength(seg){
|
||
if(!seg) return 0;
|
||
const body=seg.querySelector('.msg-body')||seg;
|
||
return String(body.textContent||'').trim().length;
|
||
}
|
||
|
||
function _mergeRestoredLiveAssistantSegment(restored, existing){
|
||
if(!restored||!existing) return;
|
||
const existingLive=existing.querySelector('[data-live-assistant="1"]');
|
||
if(!existingLive) return;
|
||
const restoredLive=restored.querySelector('[data-live-assistant="1"]');
|
||
const existingLen=_liveAssistantSegmentTextLength(existingLive);
|
||
const restoredLen=_liveAssistantSegmentTextLength(restoredLive);
|
||
if(existingLen<=restoredLen) return;
|
||
const replacement=existingLive.cloneNode(true);
|
||
if(restoredLive){
|
||
restoredLive.replaceWith(replacement);
|
||
return;
|
||
}
|
||
const blocks=_assistantTurnBlocks(restored);
|
||
if(!blocks) return;
|
||
const anchor=Array.from(blocks.children).filter(el=>
|
||
el.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row,[data-live-assistant="1"]')
|
||
).pop();
|
||
if(anchor) anchor.insertAdjacentElement('afterend', replacement);
|
||
else blocks.appendChild(replacement);
|
||
}
|
||
|
||
function restoreLiveTurnHtmlForSession(sid){
|
||
const inflight=INFLIGHT[sid];
|
||
if(!sid||!inflight||!inflight.liveTurnHtml) return false;
|
||
const inner=$('msgInner');
|
||
if(!inner) return false;
|
||
const template=document.createElement('template');
|
||
template.innerHTML=String(inflight.liveTurnHtml||'').trim();
|
||
const restored=template.content.firstElementChild;
|
||
if(!restored) return false;
|
||
restored.id='liveAssistantTurn';
|
||
if(S.session) restored.dataset.sessionId=S.session.session_id;
|
||
const existing=$('liveAssistantTurn');
|
||
_mergeRestoredLiveAssistantSegment(restored, existing);
|
||
if(existing) existing.replaceWith(restored);
|
||
else inner.appendChild(restored);
|
||
const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
|
||
if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup);
|
||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||
requestAnimationFrame(()=>postProcessRenderedMessages(restored));
|
||
return true;
|
||
}
|
||
|
||
function markInflight(sid, streamId) {
|
||
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
|
||
}
|
||
function clearInflight() {
|
||
localStorage.removeItem(INFLIGHT_KEY);
|
||
}
|
||
function showReconnectBanner(msg) {
|
||
$('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.';
|
||
$('reconnectBanner').classList.add('visible');
|
||
}
|
||
function dismissReconnect() {
|
||
$('reconnectBanner').classList.remove('visible');
|
||
clearInflight();
|
||
}
|
||
|
||
// ── Live host resource health panel (#693) ──
|
||
const SYSTEM_HEALTH_INTERVAL_MS=5000;
|
||
let _systemHealthTimer=null;
|
||
function _systemHealthPercent(metric){
|
||
const percent=Number(metric&&metric.percent);
|
||
if(!Number.isFinite(percent)) return null;
|
||
return Math.max(0,Math.min(100,Math.round(percent*10)/10));
|
||
}
|
||
function _formatSystemHealthPercent(percent){
|
||
if(percent == null) return '—';
|
||
return `${percent.toFixed(percent%1?1:0)}%`;
|
||
}
|
||
function _formatSystemHealthBytes(metric){
|
||
if(!metric||!metric.used_bytes||!metric.total_bytes) return '';
|
||
const units=['B','KB','MB','GB','TB'];
|
||
const fmt=(bytes)=>{
|
||
let value=Number(bytes)||0, idx=0;
|
||
while(value>=1024&&idx<units.length-1){value/=1024;idx++;}
|
||
return `${value.toFixed(value>=10||idx===0?0:1)} ${units[idx]}`;
|
||
};
|
||
return `${fmt(metric.used_bytes)} / ${fmt(metric.total_bytes)}`;
|
||
}
|
||
function _updateSystemHealthMetric(name,metric){
|
||
const row=document.querySelector(`[data-system-health-metric="${name}"]`);
|
||
if(!row) return;
|
||
const rawPercent=_systemHealthPercent(metric);
|
||
const percent=rawPercent == null ? 0 : rawPercent;
|
||
const label=row.querySelector('[data-system-health-value]');
|
||
const bar=row.querySelector('.system-health-bar');
|
||
const fill=row.querySelector('.system-health-bar-fill');
|
||
const text=_formatSystemHealthPercent(rawPercent);
|
||
if(label){
|
||
label.textContent=text;
|
||
const bytes=(name==='memory'||name==='disk')?_formatSystemHealthBytes(metric):'';
|
||
label.title=bytes||text;
|
||
}
|
||
if(bar) bar.setAttribute('aria-valuenow',String(percent));
|
||
if(fill) fill.style.width=`${percent}%`;
|
||
}
|
||
function setSystemHealthUnavailable(message){
|
||
const panel=$('systemHealthPanel');
|
||
const status=$('systemHealthStatus');
|
||
if(!panel) return;
|
||
panel.classList.remove('loading');
|
||
panel.classList.add('unavailable');
|
||
if(status) status.textContent=message||'Unavailable';
|
||
['cpu','memory','disk'].forEach(name=>_updateSystemHealthMetric(name,null));
|
||
}
|
||
function renderSystemHealth(payload){
|
||
const panel=$('systemHealthPanel');
|
||
const status=$('systemHealthStatus');
|
||
if(!panel) return;
|
||
if(!payload||payload.available===false){
|
||
setSystemHealthUnavailable('Unavailable');
|
||
return;
|
||
}
|
||
panel.classList.remove('loading','unavailable');
|
||
if(status) status.textContent=payload.status==='partial'?'Partial':'Live';
|
||
_updateSystemHealthMetric('cpu',payload.cpu);
|
||
_updateSystemHealthMetric('memory',payload.memory);
|
||
_updateSystemHealthMetric('disk',payload.disk);
|
||
}
|
||
async function pollSystemHealth(){
|
||
if(document.visibilityState !== 'visible') return;
|
||
if(!_systemHealthPanelIsVisible()) return;
|
||
try{
|
||
const payload=await api('/api/system/health');
|
||
renderSystemHealth(payload);
|
||
}catch(_){
|
||
setSystemHealthUnavailable('Unavailable');
|
||
}
|
||
}
|
||
function _systemHealthPanelIsVisible(){
|
||
return document.visibilityState === 'visible' &&
|
||
!!document.querySelector('main.main.showing-insights') &&
|
||
!!$('systemHealthPanel');
|
||
}
|
||
function startSystemHealthMonitor(){
|
||
if(!_systemHealthPanelIsVisible()) return;
|
||
if(_systemHealthTimer) return;
|
||
void pollSystemHealth();
|
||
_systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS);
|
||
}
|
||
function stopSystemHealthMonitor(){
|
||
if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;}
|
||
}
|
||
function _syncSystemHealthMonitorVisibility(){
|
||
if(_systemHealthPanelIsVisible()) startSystemHealthMonitor();
|
||
else stopSystemHealthMonitor();
|
||
}
|
||
document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility);
|
||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startSystemHealthMonitor);
|
||
else startSystemHealthMonitor();
|
||
|
||
// ── Hermes agent/gateway heartbeat alert (#716) ──
|
||
const AGENT_HEALTH_INTERVAL_MS=30000;
|
||
const AGENT_HEALTH_DISMISSED_KEY='agent-health-dismissed';
|
||
let _agentHealthTimer=null;
|
||
let _agentHealthLastState='unknown';
|
||
function _agentHealthDismissed(){
|
||
try{return localStorage.getItem(AGENT_HEALTH_DISMISSED_KEY)==='1';}
|
||
catch(_){return false;}
|
||
}
|
||
function _setAgentHealthDismissed(value){
|
||
try{
|
||
if(value)localStorage.setItem(AGENT_HEALTH_DISMISSED_KEY,'1');
|
||
else localStorage.removeItem(AGENT_HEALTH_DISMISSED_KEY);
|
||
}catch(_){ }
|
||
}
|
||
function _hideAgentHealthAlert(){
|
||
const banner=$('agentHealthBanner');
|
||
if(banner){banner.classList.remove('visible');banner.hidden=true;}
|
||
}
|
||
function _showAgentHealthAlert(payload){
|
||
if(_agentHealthDismissed()) return;
|
||
const banner=$('agentHealthBanner');
|
||
const title=$('agentHealthTitle');
|
||
const details=$('agentHealthDetails');
|
||
if(!banner) return;
|
||
if(title) title.textContent='Hermes agent is not responding';
|
||
const state=payload&&payload.details&&payload.details.gateway_state?` State: ${payload.details.gateway_state}.`:'';
|
||
if(details) details.textContent=`Gateway heartbeat failed.${state} Messages may not be delivered until it comes back.`;
|
||
banner.hidden=false;
|
||
banner.classList.add('visible');
|
||
}
|
||
function dismissAgentHealthAlert(){
|
||
_setAgentHealthDismissed(true);
|
||
_hideAgentHealthAlert();
|
||
}
|
||
async function pollAgentHealth(){
|
||
if(document.visibilityState !== 'visible') return;
|
||
try{
|
||
const payload=await api('/api/health/agent');
|
||
if(payload.alive === true){
|
||
_agentHealthLastState='alive';
|
||
_setAgentHealthDismissed(false);
|
||
_hideAgentHealthAlert();
|
||
return;
|
||
}
|
||
if(payload.alive === false){
|
||
_agentHealthLastState='down';
|
||
_showAgentHealthAlert(payload);
|
||
return;
|
||
}
|
||
if(payload.alive == null){
|
||
_agentHealthLastState='unknown';
|
||
_hideAgentHealthAlert();
|
||
}
|
||
}catch(_){
|
||
_agentHealthLastState='unknown';
|
||
_hideAgentHealthAlert();
|
||
}
|
||
}
|
||
function startAgentHealthMonitor(){
|
||
if(document.visibilityState !== 'visible') return;
|
||
if(_agentHealthTimer) return;
|
||
void pollAgentHealth();
|
||
_agentHealthTimer=setInterval(pollAgentHealth, AGENT_HEALTH_INTERVAL_MS);
|
||
}
|
||
function stopAgentHealthMonitor(){
|
||
if(_agentHealthTimer){clearInterval(_agentHealthTimer);_agentHealthTimer=null;}
|
||
}
|
||
function _syncAgentHealthMonitorVisibility(){
|
||
if(document.visibilityState === 'visible') startAgentHealthMonitor();
|
||
else stopAgentHealthMonitor();
|
||
}
|
||
document.addEventListener('visibilitychange',_syncAgentHealthMonitorVisibility);
|
||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startAgentHealthMonitor);
|
||
else startAgentHealthMonitor();
|
||
async function refreshSession() {
|
||
// When the banner is in post-update restart mode, the "Reload" button
|
||
// should do a full page reload — a session refresh would just 502 while
|
||
// the server is still restarting.
|
||
if (window._restartingForUpdate) { location.reload(); return; }
|
||
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,S.messages);
|
||
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 _formatUpdateTargetStatus(label,info){
|
||
if(!info||!(info.behind>0)) return null;
|
||
const release=(info.release_based&&info.latest_version)
|
||
?` (${info.current_version||'unknown'} -> ${info.latest_version})`
|
||
:(info.branch?` (${info.branch})`:'');
|
||
const noun=info.release_based?'release':'update';
|
||
return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`;
|
||
}
|
||
function _isSafeUpdateCompareUrl(url){
|
||
if(!url||!/^https?:\/\//i.test(url)) return false;
|
||
try{
|
||
const parsed=new URL(url);
|
||
return parsed.protocol==='https:'||parsed.protocol==='http:';
|
||
}catch(e){
|
||
return false;
|
||
}
|
||
}
|
||
function _updateCompareUrl(info){
|
||
if(!info) return null;
|
||
const compareUrl=info.compare_url||null;
|
||
if(compareUrl) return _isSafeUpdateCompareUrl(compareUrl)?compareUrl:null;
|
||
const repo_url=info.repo_url;
|
||
const currentSha=info.current_sha;
|
||
const latestSha=info.latest_sha;
|
||
if(!(repo_url&¤tSha&&latestSha)) return null;
|
||
const fallbackUrl=repo_url+'/compare/'+currentSha+'...'+latestSha;
|
||
return _isSafeUpdateCompareUrl(fallbackUrl)?fallbackUrl:null;
|
||
}
|
||
function _updateWhatsNewTargets(data){
|
||
const targets=[
|
||
{key:'webui',label:'WebUI',info:data&&data.webui},
|
||
{key:'agent',label:'Agent',info:data&&data.agent},
|
||
];
|
||
return targets.map((target)=>({
|
||
key:target.key,
|
||
label:target.label,
|
||
info:target.info,
|
||
url:_updateCompareUrl(target.info),
|
||
})).filter((target)=>target.info&&target.info.behind>0&&target.url);
|
||
}
|
||
function _appendUpdateDiffLinks(container,targets,prefix){
|
||
if(!container) return;
|
||
if(prefix) container.appendChild(document.createTextNode(prefix));
|
||
targets.forEach((target,idx)=>{
|
||
if(idx>0) container.appendChild(document.createTextNode(' \u00b7 '));
|
||
const link=document.createElement('a');
|
||
link.href=target.url;
|
||
link.target='_blank';
|
||
link.rel='noopener';
|
||
link.style.color='var(--accent)';
|
||
link.style.textDecoration='underline';
|
||
link.textContent=target.label;
|
||
container.appendChild(link);
|
||
});
|
||
}
|
||
function _hideUpdateSummaryPanel(){
|
||
const panel=$('updateSummaryPanel');
|
||
const text=$('updateSummaryText');
|
||
const links=$('updateSummaryDiffLinks');
|
||
if(panel) panel.style.display='none';
|
||
if(text) text.textContent='';
|
||
if(links){links.replaceChildren();links.style.display='none';}
|
||
}
|
||
const WHATS_NEW_SUMMARY_STORAGE_KEY='hermes-whats-new-generated-summaries';
|
||
function _loadStoredUpdateSummaries(){
|
||
window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{};
|
||
try{
|
||
const raw=sessionStorage.getItem(WHATS_NEW_SUMMARY_STORAGE_KEY);
|
||
if(!raw) return window._whatsNewGeneratedSummaries;
|
||
const stored=JSON.parse(raw);
|
||
if(stored&&typeof stored==='object') window._whatsNewGeneratedSummaries=stored;
|
||
}catch(_e){
|
||
try{sessionStorage.removeItem(WHATS_NEW_SUMMARY_STORAGE_KEY);}catch(_ignore){}
|
||
}
|
||
return window._whatsNewGeneratedSummaries;
|
||
}
|
||
function _persistGeneratedSummaries(){
|
||
try{sessionStorage.setItem(WHATS_NEW_SUMMARY_STORAGE_KEY,JSON.stringify(window._whatsNewGeneratedSummaries||{}));}catch(_e){}
|
||
}
|
||
function _pruneGeneratedSummaries(data){
|
||
const cache=_loadStoredUpdateSummaries();
|
||
const valid=new Set(_updateWhatsNewTargets(data||{}).map((target)=>target.key));
|
||
let changed=false;
|
||
Object.keys(cache).forEach((key)=>{
|
||
if(!valid.has(key)){delete cache[key];changed=true;}
|
||
});
|
||
if(changed) _persistGeneratedSummaries();
|
||
}
|
||
function _updateSummarySignature(info){
|
||
if(!info) return '';
|
||
return [info.current_sha||'',info.latest_sha||'',info.behind||0,info.compare_url||''].join('|');
|
||
}
|
||
function _updateSummaryButtonLabel(target,data){
|
||
const labels=target.key==='webui'
|
||
? {generate:'Generate WebUI update summary',view:'View generated WebUI update summary',regenerate:'Re-generate WebUI update summary'}
|
||
: {generate:'Generate Agent update summary',view:'View generated Agent update summary',regenerate:'Re-generate Agent update summary'};
|
||
const cache=_loadStoredUpdateSummaries()[target.key];
|
||
const signature=_updateSummarySignature(data&&data[target.key]);
|
||
if(cache&&cache.signature===signature&&cache.payload) return labels.view;
|
||
if(cache&&cache.signature!==signature) return labels.regenerate;
|
||
return labels.generate;
|
||
}
|
||
function _rememberGeneratedSummary(target,payload,data){
|
||
if(!target) return;
|
||
window._whatsNewGeneratedSummaries=window._whatsNewGeneratedSummaries||{};
|
||
window._whatsNewGeneratedSummaries[target]={
|
||
signature:_updateSummarySignature(data&&data[target]),
|
||
payload:payload,
|
||
};
|
||
_persistGeneratedSummaries();
|
||
}
|
||
function _renderUpdateSummaryPanel(payload,data,targetKey){
|
||
const panel=$('updateSummaryPanel');
|
||
const text=$('updateSummaryText');
|
||
const links=$('updateSummaryDiffLinks');
|
||
if(!panel||!text) return;
|
||
panel.style.display='block';
|
||
const sections=Array.isArray(payload&&payload.summary_sections)?payload.summary_sections:null;
|
||
text.replaceChildren();
|
||
if(sections&§ions.length){
|
||
const wrap=document.createElement('div');
|
||
wrap.id='updateSummarySections';
|
||
wrap.style.display='grid';
|
||
wrap.style.gap='8px';
|
||
sections.forEach((section)=>{
|
||
const block=document.createElement('section');
|
||
const title=document.createElement('div');
|
||
title.style.fontWeight='650';
|
||
title.style.marginBottom='3px';
|
||
title.textContent=section.title||'Summary';
|
||
block.appendChild(title);
|
||
const ul=document.createElement('ul');
|
||
ul.style.margin='0';
|
||
ul.style.paddingLeft='18px';
|
||
(Array.isArray(section.items)?section.items:[]).forEach((item)=>{
|
||
const li=document.createElement('li');
|
||
li.textContent=String(item||'').trim();
|
||
if(li.textContent) ul.appendChild(li);
|
||
});
|
||
if(!ul.children.length){
|
||
const li=document.createElement('li');
|
||
li.textContent='No summary details available.';
|
||
ul.appendChild(li);
|
||
}
|
||
block.appendChild(ul);
|
||
wrap.appendChild(block);
|
||
});
|
||
text.appendChild(wrap);
|
||
}else{
|
||
text.textContent=(payload&&payload.summary)||payload||'No summary available.';
|
||
}
|
||
const targets=_updateWhatsNewTargets(data||window._updateData||{}).filter((target)=>!targetKey||target.key===targetKey);
|
||
if(links){
|
||
links.replaceChildren();
|
||
if(targets.length){
|
||
links.style.display='block';
|
||
_appendUpdateDiffLinks(links,targets,'Regular diff comparison: ');
|
||
}else{
|
||
links.style.display='none';
|
||
}
|
||
}
|
||
}
|
||
async function showWhatsNewSummary(target){
|
||
const data=window._updateData||{};
|
||
const scopedUpdates=target?{[target]:data[target]}:data;
|
||
const cache=target?_loadStoredUpdateSummaries()[target]:null;
|
||
const signature=target?_updateSummarySignature(data[target]):'';
|
||
if(cache&&cache.signature===signature&&cache.payload){
|
||
_renderUpdateSummaryPanel(cache.payload,data,target);
|
||
_renderUpdateWhatsNewLinks(data,{mode:'summary'});
|
||
return;
|
||
}
|
||
_renderUpdateSummaryPanel({summary:'Writing a simple summary…'},data,target);
|
||
try{
|
||
const res=await api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null})});
|
||
_rememberGeneratedSummary(target,res,data);
|
||
_renderUpdateSummaryPanel(res,data,target);
|
||
_renderUpdateWhatsNewLinks(data,{mode:'summary'});
|
||
}catch(e){
|
||
console.warn('[updates] summary failed',e);
|
||
_renderUpdateSummaryPanel({
|
||
summary_sections:[
|
||
{title:"What you'll notice",items:['Could not generate the summary right now.']},
|
||
{title:'Worth knowing',items:['Try again later, or use the comparison links below for the raw update details.']},
|
||
],
|
||
},data,target);
|
||
}
|
||
}
|
||
function _renderUpdateWhatsNewLinks(data){
|
||
const options=arguments.length>1&&arguments[1]?arguments[1]:{};
|
||
const container=$('updateWhatsNewLinks');
|
||
if(!container) return;
|
||
container.replaceChildren();
|
||
const targets=_updateWhatsNewTargets(data);
|
||
if(!targets.length){
|
||
container.style.display='none';
|
||
_hideUpdateSummaryPanel();
|
||
return;
|
||
}
|
||
container.style.display='block';
|
||
_pruneGeneratedSummaries(data);
|
||
const useSummary=(options.mode||'')==='summary'||window._whatsNewSummaryEnabled===true;
|
||
if(useSummary){
|
||
targets.forEach((target,idx)=>{
|
||
if(idx>0) container.appendChild(document.createTextNode(' \u00b7 '));
|
||
const btn=document.createElement('button');
|
||
btn.type='button';
|
||
btn.className='linklike';
|
||
btn.style.color='var(--accent)';
|
||
btn.style.textDecoration='underline';
|
||
btn.style.background='none';
|
||
btn.style.border='0';
|
||
btn.style.padding='0';
|
||
btn.style.cursor='pointer';
|
||
btn.textContent=_updateSummaryButtonLabel(target,data);
|
||
btn.onclick=()=>showWhatsNewSummary(target.key);
|
||
container.appendChild(btn);
|
||
});
|
||
return;
|
||
}
|
||
_hideUpdateSummaryPanel();
|
||
if(targets.length===1){
|
||
const target=targets[0];
|
||
const link=document.createElement('a');
|
||
link.href=target.url;
|
||
link.target='_blank';
|
||
link.rel='noopener';
|
||
link.style.color='var(--accent)';
|
||
link.style.textDecoration='underline';
|
||
link.textContent="What's new in "+target.label+'?';
|
||
container.appendChild(link);
|
||
return;
|
||
}
|
||
_appendUpdateDiffLinks(container,targets,"What's new: ");
|
||
}
|
||
function _showUpdateBanner(data){
|
||
const parts=[];
|
||
const webuiPart=_formatUpdateTargetStatus('WebUI',data.webui);
|
||
const agentPart=_formatUpdateTargetStatus('Agent',data.agent);
|
||
if(webuiPart) parts.push(webuiPart);
|
||
if(agentPart) parts.push(agentPart);
|
||
window._updateData=data;
|
||
if(!parts.length){
|
||
_renderUpdateWhatsNewLinks(data);
|
||
const staleBanner=$('updateBanner');
|
||
if(staleBanner) staleBanner.classList.remove('visible');
|
||
return;
|
||
}
|
||
const msg=$('updateMsg');
|
||
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
|
||
const banner=$('updateBanner');
|
||
if(banner) banner.classList.add('visible');
|
||
const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff';
|
||
_renderUpdateWhatsNewLinks(data,{mode:summaryMode});
|
||
}
|
||
function dismissUpdate(){
|
||
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||
sessionStorage.setItem('hermes-update-dismissed','1');
|
||
}
|
||
function _isUpdateApplyNetworkError(error){
|
||
if(error && error.status) return false;
|
||
const message=(error&&error.message)||String(error||'');
|
||
return /Failed to fetch|NetworkError|Load failed/i.test(message);
|
||
}
|
||
function _formatUpdateApplyExceptionMessage(error){
|
||
if(_isUpdateApplyNetworkError(error)){
|
||
return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.';
|
||
}
|
||
const message=(error&&error.message)||String(error||'unknown error');
|
||
return 'Update failed: '+message;
|
||
}
|
||
async function applyUpdates(){
|
||
if(window._updateApplyInFlight) return;
|
||
window._updateApplyInFlight=true;
|
||
const btn=$('btnApplyUpdate');
|
||
const resetApplyButton=(delayMs)=>{
|
||
const reset=()=>{
|
||
window._updateApplyInFlight=false;
|
||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||
};
|
||
if(delayMs>0) setTimeout(reset,delayMs);
|
||
else reset();
|
||
};
|
||
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||
const errEl=$('updateError');
|
||
if(errEl){errEl.style.display='none';errEl.textContent='';}
|
||
// Hide any leftover force-update button from a prior conflict so a fresh
|
||
// retry starts clean (otherwise stale state points at the wrong target).
|
||
const forceBtnReset=$('btnForceUpdate');
|
||
if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';}
|
||
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){
|
||
_showUpdateError(target,res);
|
||
resetApplyButton(0);
|
||
return;
|
||
}
|
||
}
|
||
showToast('Update applied — restarting…');
|
||
sessionStorage.removeItem('hermes-update-checked');
|
||
sessionStorage.removeItem('hermes-update-dismissed');
|
||
_waitForServerThenReload();
|
||
}catch(e){
|
||
const msg=_formatUpdateApplyExceptionMessage(e);
|
||
if(errEl){errEl.textContent=msg;errEl.style.display='block';}
|
||
else showToast(msg);
|
||
resetApplyButton(_isUpdateApplyNetworkError(e)?5000:0);
|
||
}
|
||
}
|
||
function _showUpdateError(target,res){
|
||
const errEl=$('updateError');
|
||
const forceBtn=$('btnForceUpdate');
|
||
const msg='Update failed ('+target+'): '+(res.message||'unknown error');
|
||
if(errEl){
|
||
errEl.textContent=msg;
|
||
errEl.style.display='block';
|
||
} else {
|
||
showToast(msg);
|
||
}
|
||
// Show "Force update" button when the error is recoverable by a hard reset
|
||
if(forceBtn&&(res.conflict||res.diverged)){
|
||
forceBtn.dataset.target=target;
|
||
forceBtn.style.display='inline-block';
|
||
}
|
||
}
|
||
async function forceUpdate(btn){
|
||
const target=btn&&btn.dataset.target;
|
||
if(!target) return;
|
||
const confirmed=await showConfirmDialog({
|
||
title:'Force update '+target+'?',
|
||
message:'This will discard all local changes in the '+target+' repo and reset to the latest remote version. This cannot be undone.',
|
||
confirmLabel:'Force update',
|
||
danger:true,
|
||
focusCancel:true,
|
||
});
|
||
if(!confirmed) return;
|
||
btn.disabled=true;btn.textContent='Force updating\u2026';
|
||
const errEl=$('updateError');
|
||
if(errEl){errEl.style.display='none';}
|
||
try{
|
||
const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify({target})});
|
||
if(!res.ok){
|
||
if(errEl){errEl.textContent='Force update failed: '+(res.message||'unknown error');errEl.style.display='block';}
|
||
btn.disabled=false;btn.textContent='Force update';
|
||
return;
|
||
}
|
||
showToast('Force update applied — restarting…');
|
||
sessionStorage.removeItem('hermes-update-checked');
|
||
sessionStorage.removeItem('hermes-update-dismissed');
|
||
_waitForServerThenReload();
|
||
}catch(e){
|
||
if(errEl){errEl.textContent='Force update failed: '+e.message;errEl.style.display='block';}
|
||
btn.disabled=false;btn.textContent='Force update';
|
||
}
|
||
}
|
||
|
||
// Poll /health after an update-triggered restart, then reload. Replaces the
|
||
// blind setTimeout(reload, 2500) that race-lost against slow hardware or
|
||
// reverse proxies that 502 immediately when the upstream socket closes (#874).
|
||
async function _waitForServerThenReload(opts){
|
||
// Polls the /health endpoint; implementation uses a relative URL so subpath mounts keep working.
|
||
opts=opts||{};
|
||
const interval=opts.interval||500;
|
||
const maxMs=opts.maxMs||15000;
|
||
window._restartingForUpdate=true;
|
||
const msgEl=$('reconnectMsg');
|
||
const banner=$('reconnectBanner');
|
||
if(msgEl) msgEl.textContent='⏳ Restarting… please wait';
|
||
if(banner) banner.classList.add('visible');
|
||
const deadline=Date.now()+maxMs;
|
||
// Give the server a moment to actually begin its restart before the first
|
||
// probe — otherwise the old process may still respond ok on the first poll.
|
||
await new Promise(r=>setTimeout(r, interval));
|
||
while(Date.now()<deadline){
|
||
try{
|
||
const r=await fetch(new URL('health', document.baseURI||location.href).href,{cache:'no-store'});
|
||
if(r.ok){
|
||
let data={};
|
||
try{ data=await r.json(); }catch(_){}
|
||
if(data && data.status==='ok'){
|
||
location.reload();
|
||
return;
|
||
}
|
||
}
|
||
}catch(_){ /* socket closed during restart — retry */ }
|
||
await new Promise(r=>setTimeout(r, interval));
|
||
}
|
||
if(msgEl) msgEl.textContent='⚠️ Server is taking longer than expected — click Reload when ready';
|
||
}
|
||
|
||
function getPendingSessionMessage(session, messagesOverride=null){
|
||
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 sourceMessages=Array.isArray(messagesOverride)?messagesOverride:session?.messages;
|
||
const messages=Array.isArray(sourceMessages)?sourceMessages:[];
|
||
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 _syncWorkspaceHeadingState==='function') _syncWorkspaceHeadingState();
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
if(typeof syncTerminalButton==='function') syncTerminalButton();
|
||
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
||
else {
|
||
const sidebarName=$('sidebarWsName');
|
||
if(sidebarName && sidebarName.textContent==='Workspace'){
|
||
sidebarName.textContent=t('no_workspace');
|
||
}
|
||
}
|
||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||
// Update profile chip even when no session is active (e.g. right after profile switch)
|
||
const _profileLabel=$('profileChipLabel');
|
||
if(_profileLabel) _profileLabel.textContent=S.activeProfile||'default';
|
||
return;
|
||
}
|
||
const sessionTitle=S.session.title||t('untitled');
|
||
const _topbarTitle=$('topbarTitle');if(_topbarTitle)_topbarTitle.textContent=sessionTitle;
|
||
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
|
||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||
const _topbarMeta=$('topbarMeta');
|
||
if(_topbarMeta){
|
||
const sourceLabel=(S.session&&S.session.is_cli_session&&(S.session.source_label||S.session.source_tag||S.session.raw_source))||'';
|
||
const metaText=t('n_messages',vis.length);
|
||
_topbarMeta.textContent=metaText;
|
||
if(sourceLabel){
|
||
const badge=document.createElement('span');
|
||
badge.className='topbar-source-badge';
|
||
badge.textContent=sourceLabel+(S.session.read_only?' · read-only':'');
|
||
_topbarMeta.appendChild(document.createTextNode(' '));
|
||
_topbarMeta.appendChild(badge);
|
||
}
|
||
}
|
||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||
if(typeof _syncWorkspaceHeadingState==='function') _syncWorkspaceHeadingState();
|
||
// 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;
|
||
const providerOverride=S._pendingProfileModelProvider||null;
|
||
S._pendingProfileModelProvider=null;
|
||
_applyModelToDropdown(modelOverride,$('modelSelect'),providerOverride);
|
||
currentModel=modelOverride;
|
||
} else {
|
||
const modelSel=$('modelSelect');
|
||
const rawCurrentModel=String(currentModel||'').trim();
|
||
const hasSessionModel=rawCurrentModel&&rawCurrentModel.toLowerCase()!=='unknown';
|
||
if(!hasSessionModel){
|
||
// Missing/unknown session metadata must not leave the picker on the
|
||
// previously viewed chat's model (#1771). Apply the configured default
|
||
// first, then the first available option only as an HTML fallback.
|
||
const fallback=_applySessionModelFallback(modelSel);
|
||
if(fallback){
|
||
// Defer state mutation + network write while the live model resolution
|
||
// is in flight — sessions.js sets _modelResolutionDeferred=true between
|
||
// the fast-path session render and the resolve_model=1 round-trip.
|
||
// Persisting here would race that resolution and would also issue
|
||
// silent /api/session/update POSTs against imported/read-only CLI
|
||
// sessions whose model field reads "unknown" (#1779 stage-310 review).
|
||
// The visible sel.value change still happens above for UX; only the
|
||
// state mutation + persist defers.
|
||
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
|
||
if(!deferModelCorrection){
|
||
S.session.model=fallback.model;
|
||
S.session.model_provider=fallback.model_provider||null;
|
||
currentModel=fallback.model;
|
||
_persistSessionModelCorrection(fallback.model,S.session.model_provider||null);
|
||
}
|
||
}
|
||
} else {
|
||
const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null);
|
||
// If the model isn't in the current provider list, reset to the configured
|
||
// default rather than silently retaining the previous chat's selection (#1771).
|
||
if(!applied){
|
||
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
|
||
const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null);
|
||
// Also defer if a live model fetch is still in flight — the model may be
|
||
// in the list once the fetch completes. Persisting now would corrupt the
|
||
// session with the wrong model before live models arrive (#1169).
|
||
const liveStillPending=window._activeProvider&&_liveModelFetchPending.has(window._activeProvider);
|
||
if(liveStillPending||missingModelIsRoutable){
|
||
// Live fetch in flight — don't touch sel.value or S.session.model yet.
|
||
// _addLiveModelsToSelect() will re-apply S.session.model once done (#1169).
|
||
// Named custom providers/OpenRouter can also route vendor-prefixed IDs
|
||
// outside the static catalog, so preserve the user's explicit choice.
|
||
} else {
|
||
const fallback=_applySessionModelFallback(modelSel);
|
||
if(fallback&&!deferModelCorrection){
|
||
S.session.model=fallback.model;
|
||
S.session.model_provider=fallback.model_provider||null;
|
||
currentModel=fallback.model;
|
||
// Persist the correction so the session doesn't re-inject on next load.
|
||
_persistSessionModelCorrection(fallback.model,S.session.model_provider||null);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if(typeof syncModelChip==='function') syncModelChip();
|
||
if(typeof syncReasoningChip==='function') syncReasoningChip();
|
||
if(typeof syncToolsetsChip==='function') syncToolsetsChip();
|
||
// 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();
|
||
if(typeof syncTerminalButton==='function') syncTerminalButton();
|
||
// 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();
|
||
}
|
||
|
||
function _fmtDateSep(d){
|
||
const todayStart=new Date();todayStart.setHours(0,0,0,0);
|
||
const dStart=new Date(d);dStart.setHours(0,0,0,0);
|
||
const diffDays=Math.round((todayStart-dStart)/86400000);
|
||
if(diffDays===0) return 'Today';
|
||
if(diffDays===1) return 'Yesterday';
|
||
if(diffDays>0 && diffDays<7) return dStart.toLocaleDateString([], {weekday:'long'});
|
||
const opts={month:'short', day:'numeric'};
|
||
if(todayStart.getFullYear()!==dStart.getFullYear()) opts.year='numeric';
|
||
return dStart.toLocaleDateString([], opts);
|
||
}
|
||
const _ERR_MSG_RE=/^(?:\*\*error\b|error:|connection lost|no response received)/i;
|
||
function _messageHasReasoningPayload(m){
|
||
if(!m||m.role!=='assistant') return false;
|
||
if(m.reasoning) return true;
|
||
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
|
||
return /^\s*(?:<think>[\s\S]*?<\/think>|<\|channel\|?>thought\n?[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>)/.test(String(m.content||''));
|
||
}
|
||
function _formatTurnTps(value){
|
||
const n=Number(value);
|
||
if(!Number.isFinite(n)||n<=0) return '';
|
||
const fixed=n>=100?Math.round(n).toLocaleString():n>=10?n.toFixed(1):n.toFixed(1);
|
||
return `${fixed} t/s`;
|
||
}
|
||
function isTpsDisplayEnabled(){
|
||
return window._showTps===true;
|
||
}
|
||
function _assistantRoleHtml(tsTitle='', tpsText=''){
|
||
const _bn=window._botName||'Hermes';
|
||
const tps=(isTpsDisplayEnabled()&&tpsText)?`<span class="msg-tps-inline" title="Tokens per second">${esc(tpsText)}</span>`:'';
|
||
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span>${tps}</div>`;
|
||
}
|
||
function _setAssistantTurnTps(turn, tpsText=''){
|
||
if(!turn) return;
|
||
const role=turn.querySelector('.msg-role.assistant');
|
||
if(!role) return;
|
||
let chip=role.querySelector('.msg-tps-inline');
|
||
const text=String(tpsText||'').trim();
|
||
if(!text){if(chip) chip.remove();return;}
|
||
if(!chip){
|
||
chip=document.createElement('span');
|
||
chip.className='msg-tps-inline';
|
||
chip.title='Tokens per second';
|
||
role.appendChild(chip);
|
||
}
|
||
chip.textContent=text;
|
||
}
|
||
function _setLiveAssistantTps(value){
|
||
_setAssistantTurnTps($('liveAssistantTurn'), isTpsDisplayEnabled()?_formatTurnTps(value):'');
|
||
}
|
||
function _createAssistantTurn(tsTitle='', tpsText=''){
|
||
const row=document.createElement('div');
|
||
row.className='msg-row assistant-turn';
|
||
row.dataset.role='assistant';
|
||
if(S.session) row.dataset.sessionId=S.session.session_id;
|
||
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
|
||
return row;
|
||
}
|
||
function _assistantTurnBlocks(turn){
|
||
return turn?turn.querySelector('.assistant-turn-blocks'):null;
|
||
}
|
||
function _thinkingCardHtml(text, open){
|
||
const clean=_sanitizeThinkingDisplayText(text);
|
||
const copyBtn=`<button class="thinking-copy-btn" onclick="event.stopPropagation();_copyThinkingText(this)" title="${t('copy')}" aria-label="${t('copy')}">${li('copy',12)}</button>`;
|
||
const classes=`thinking-card${open?' open':''}`;
|
||
return `<div class="${classes}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-btn-row">${copyBtn}<span class="thinking-card-toggle">${li('chevron-right',12)}</span></span></div><div class="thinking-card-body"><pre>${esc(clean)}</pre></div></div>`;
|
||
}
|
||
function isSimplifiedToolCalling(){
|
||
return window._simplifiedToolCalling!==false;
|
||
}
|
||
function _thinkingActivityNode(text, open){
|
||
const row=document.createElement('div');
|
||
row.className='agent-activity-thinking';
|
||
row.innerHTML=_thinkingCardHtml(text, open);
|
||
return row;
|
||
}
|
||
// ── Activity-group user expand intent (#1298) ──────────────────────────────
|
||
// When the user manually expands the live "Activity" dropdown during streaming,
|
||
// preserve that intent across the destroy/recreate cycle that fires on every
|
||
// thinking/tool event. Without this, ensureActivityGroup() re-creates the group
|
||
// with the default collapsed state and finalizeThinkingCard() force-collapses
|
||
// it whenever the assistant transitions from thinking → tool → thinking, so
|
||
// the panel snaps shut every few seconds while the user is trying to read it.
|
||
//
|
||
// The tracker is a singleton boolean: there is at most one live activity group
|
||
// at a time (selector .tool-call-group[data-live-tool-call-group="1"]). It is
|
||
// set to true when the user clicks the summary to expand, false when they
|
||
// click to collapse, and cleared back to undefined when the live group is
|
||
// finalized into a settled assistant turn (the live attribute is removed in
|
||
// _convertLiveActivityGroupToSettled / when liveAssistantTurn loses its id).
|
||
let _liveActivityUserExpanded;
|
||
const _activityDisclosureStoragePrefix='hermes-activity-disclosure:';
|
||
function _activityDisclosureStorageKey(activityKey){
|
||
if(!activityKey||!S.session||!S.session.session_id) return null;
|
||
return _activityDisclosureStoragePrefix+S.session.session_id+':'+activityKey;
|
||
}
|
||
function _readActivityDisclosureState(activityKey){
|
||
const key=_activityDisclosureStorageKey(activityKey);
|
||
if(!key) return null;
|
||
try{
|
||
const saved=localStorage.getItem(key);
|
||
return saved==='open'||saved==='closed'?saved:null;
|
||
}catch(_){return null;}
|
||
}
|
||
function _writeActivityDisclosureState(activityKey, open){
|
||
const key=_activityDisclosureStorageKey(activityKey);
|
||
if(!key) return;
|
||
try{localStorage.setItem(key, open?'open':'closed');}catch(_){}
|
||
}
|
||
function _copyActivityDisclosureState(fromActivityKey, toActivityKey){
|
||
const state=_readActivityDisclosureState(fromActivityKey);
|
||
if(state) _writeActivityDisclosureState(toActivityKey, state==='open');
|
||
}
|
||
function _activityKeyForLiveTurn(){
|
||
return S.activeStreamId?'live:'+S.activeStreamId:null;
|
||
}
|
||
function _onLiveActivityToggle(group){
|
||
if(!group) return;
|
||
// Only track explicit user clicks on the live group, not programmatic toggles.
|
||
if(group.getAttribute('data-live-tool-call-group')!=='1') return;
|
||
_liveActivityUserExpanded = !group.classList.contains('tool-call-group-collapsed');
|
||
}
|
||
function _toggleActivityGroup(summary){
|
||
const group=summary&&summary.closest?summary.closest('.tool-call-group'):null;
|
||
if(!group) return;
|
||
const collapsed=group.classList.toggle('tool-call-group-collapsed');
|
||
summary.setAttribute('aria-expanded',String(!collapsed));
|
||
_writeActivityDisclosureState(group.getAttribute('data-activity-disclosure-key'), !collapsed);
|
||
if(typeof _onLiveActivityToggle==='function') _onLiveActivityToggle(group);
|
||
}
|
||
function _clearLiveActivityUserIntent(){
|
||
_liveActivityUserExpanded = undefined;
|
||
}
|
||
function ensureActivityGroup(inner, opts){
|
||
opts=opts||{};
|
||
if(!inner) return null;
|
||
const live=!!opts.live;
|
||
const activityKey=opts.activityKey||(live?_activityKeyForLiveTurn():null);
|
||
const selector=live?'.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]':'.tool-call-group[data-agent-activity-group="1"]';
|
||
let group=inner.querySelector(selector);
|
||
if(!group){
|
||
group=document.createElement('div');
|
||
let collapsed=opts.collapsed!==false;
|
||
const savedState=_readActivityDisclosureState(activityKey);
|
||
// Restore the user's explicit expand intent when recreating the live
|
||
// activity group within the same turn (#1298), then let persisted chat/turn
|
||
// state win across session switches and reloads.
|
||
if(live && _liveActivityUserExpanded === true) collapsed=false;
|
||
else if(live && _liveActivityUserExpanded === false) collapsed=true;
|
||
if(savedState==='open') collapsed=false;
|
||
else if(savedState==='closed') collapsed=true;
|
||
group.className='tool-call-group agent-activity-group'+(collapsed?' tool-call-group-collapsed':'');
|
||
group.setAttribute('data-tool-call-group','1');
|
||
group.setAttribute('data-agent-activity-group','1');
|
||
if(activityKey) group.setAttribute('data-activity-disclosure-key',activityKey);
|
||
if(live){
|
||
group.setAttribute('data-live-tool-call-group','1');
|
||
group.setAttribute('data-live-activity-current','1');
|
||
}
|
||
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="_toggleActivityGroup(this)"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-duration"></span></button><div class="tool-call-group-body"></div>`;
|
||
const anchor=opts.anchor||null;
|
||
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
|
||
else inner.appendChild(group);
|
||
}else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){
|
||
group.setAttribute('data-activity-disclosure-key',activityKey);
|
||
}
|
||
if(live) _setActivityElapsedStartedAt(group);
|
||
_syncToolCallGroupSummary(group);
|
||
if(live) _startActivityElapsedTimer(group);
|
||
return group;
|
||
}
|
||
function closeCurrentLiveActivityGroup(){
|
||
const turn=$('liveAssistantTurn');
|
||
if(!turn) return;
|
||
turn.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{
|
||
group.removeAttribute('data-live-activity-current');
|
||
});
|
||
}
|
||
function _compressionStateForCurrentSession(){
|
||
const state=window._compressionUi;
|
||
if(!state||!S.session||state.sessionId!==S.session.session_id) return null;
|
||
return state;
|
||
}
|
||
function isCompressionUiRunning(){
|
||
const state=_compressionStateForCurrentSession();
|
||
const lock=_compressionSessionLock();
|
||
return !!((state&&state.phase==='running') || (lock && S.session && lock===S.session.session_id));
|
||
}
|
||
function clearCompressionUi(){
|
||
window._compressionUi=null;
|
||
_clearCompressionElapsedTimer();
|
||
_setCompressionSessionLock(null);
|
||
renderCompressionUi();
|
||
}
|
||
function setCompressionUi(state){
|
||
if(!state){
|
||
clearCompressionUi();
|
||
return;
|
||
}
|
||
const nextState={...state};
|
||
if(nextState.automatic&&nextState.phase==='running'&&!_compressionElapsedStartedAt(nextState)){
|
||
nextState.startedAt=Date.now()/1000;
|
||
}
|
||
window._compressionUi=nextState;
|
||
if(nextState.sessionId) _setCompressionSessionLock(nextState.sessionId);
|
||
if(nextState.automatic&&nextState.phase==='running') _startCompressionElapsedTimer();
|
||
else _clearCompressionElapsedTimer();
|
||
renderCompressionUi();
|
||
}
|
||
function _compressionCardsHtml(state){
|
||
if(!state) return '';
|
||
if(state.automatic) return _autoCompressionCardsHtml(state);
|
||
const cmdText=state.commandText||'/compress';
|
||
const focusText=state.focusTopic?`${t('focus_label')}: ${state.focusTopic}`:'';
|
||
const headerText=state.phase==='done'
|
||
? (state.summary?.headline||t('compress_complete_label'))
|
||
: state.phase==='error'
|
||
? (state.errorText||t('compress_failed_label'))
|
||
: (typeof state.beforeCount==='number' ? t('n_messages', state.beforeCount) : '');
|
||
const statusBody=state.phase==='error'
|
||
? [state.errorText||t('compress_failed_label'), focusText].filter(Boolean).join('\n')
|
||
: [t('compressing'), focusText].filter(Boolean).join('\n');
|
||
const statusLabel=state.phase==='done'
|
||
? t('compress_complete_label')
|
||
: state.phase==='error'
|
||
? t('compress_failed_label')
|
||
: t('compress_running_label');
|
||
const statusIcon=state.phase==='done'
|
||
? li('check',13)
|
||
: state.phase==='error'
|
||
? li('x',13)
|
||
: `<span class="tool-card-running-dot"></span>`;
|
||
const doneCardHtml=state.phase==='done'
|
||
? _compressionStatusCardHtml({
|
||
statusLabel,
|
||
previewText: headerText,
|
||
detail: [state.summary?.token_line, state.summary?.note, focusText].filter(Boolean).join('\n'),
|
||
icon: statusIcon,
|
||
open: true,
|
||
variantClass: 'tool-card-compress-complete',
|
||
})
|
||
: '';
|
||
const referenceHtml=(state.phase==='done'&&state.referenceText)
|
||
? _compressionReferenceCardHtml(state.referenceText, false)
|
||
: '';
|
||
return `
|
||
<div class="tool-card-row compression-card-row" data-compression-card="1">
|
||
<div class="tool-card tool-card-compress-command">
|
||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||
<span class="tool-card-icon">${li('settings',13)}</span>
|
||
<span class="tool-card-name">${esc(t('command_label'))}</span>
|
||
<span class="tool-card-preview">${esc(cmdText)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tool-card-row compression-card-row" data-compression-card="1">
|
||
${state.phase==='done'
|
||
? doneCardHtml
|
||
: _compressionStatusCardHtml({
|
||
statusLabel,
|
||
previewText: headerText,
|
||
detail: statusBody,
|
||
icon: statusIcon,
|
||
open: false,
|
||
variantClass: state.phase==='error'
|
||
? 'tool-card-compress-error'
|
||
: 'tool-card-compress-running',
|
||
})
|
||
}
|
||
</div>
|
||
${referenceHtml}`;
|
||
}
|
||
function _autoCompressionBaseDetail(state){
|
||
const fallback='Context auto-compressed to continue the conversation';
|
||
const running=state&&state.phase==='running';
|
||
return running
|
||
? (String(state.message||'Auto-compressing context...').trim()||'Auto-compressing context...')
|
||
: (String(state&&state.message||fallback).trim()||fallback);
|
||
}
|
||
function _autoCompressionPreviewText(state){
|
||
const running=state&&state.phase==='running';
|
||
const detail=_autoCompressionBaseDetail(state);
|
||
if(!running) return (String(state&&state.summary?.headline||detail).trim()||detail);
|
||
const elapsedLabel=_compressionElapsedLabel(state);
|
||
return [detail, elapsedLabel].filter(Boolean).join(' · ');
|
||
}
|
||
function _autoCompressionDetailText(state){
|
||
const running=state&&state.phase==='running';
|
||
const base=_autoCompressionBaseDetail(state);
|
||
const elapsedLabel=running?_compressionElapsedLabel(state):'';
|
||
if(running)return elapsedLabel?`Elapsed: ${elapsedLabel}`:base;
|
||
const continuation=String(state&&state.continuationSessionId||'').trim();
|
||
const handoff=continuation?`Continued in compressed session: ${continuation}`:'';
|
||
return [base,handoff].filter(Boolean).join('\n');
|
||
}
|
||
function _autoCompressionCardsHtml(state){
|
||
const running=state&&state.phase==='running';
|
||
const preview=_autoCompressionPreviewText(state);
|
||
const cardDetail=_autoCompressionDetailText(state);
|
||
return `
|
||
<div class="tool-card-row compression-card-row" data-compression-card="1">
|
||
${_compressionStatusCardHtml({
|
||
statusLabel: t('auto_compress_label'),
|
||
previewText: preview,
|
||
detail: cardDetail,
|
||
icon: running ? '<span class="tool-card-running-dot"></span>' : li('check',13),
|
||
open: running,
|
||
variantClass: running
|
||
? 'tool-card-compress-running tool-card-compress-auto'
|
||
: 'tool-card-compress-complete tool-card-compress-auto',
|
||
})}
|
||
</div>`;
|
||
}
|
||
function _compressionCardsNode(state){
|
||
const wrap=document.createElement('div');
|
||
wrap.className='compression-turn';
|
||
wrap.innerHTML=`<div class="compression-turn-blocks">${_compressionCardsHtml(state)}</div>`;
|
||
return wrap;
|
||
}
|
||
function appendLiveCompressionCard(state){
|
||
if(!S.session||!S.activeStreamId||!state) return false;
|
||
let turn=$('liveAssistantTurn');
|
||
if(!turn){
|
||
turn=_createAssistantTurn();
|
||
turn.id='liveAssistantTurn';
|
||
if(S.session) turn.dataset.sessionId=S.session.session_id;
|
||
$('msgInner').appendChild(turn);
|
||
}
|
||
const inner=_assistantTurnBlocks(turn);
|
||
if(!inner) return false;
|
||
closeCurrentLiveActivityGroup();
|
||
const node=_compressionCardsNode(state);
|
||
if(!node) return false;
|
||
node.setAttribute('data-live-compression-card','1');
|
||
if(state.automatic&&state.phase==='running'){
|
||
const started=_compressionElapsedStartedAt(state)||Date.now()/1000;
|
||
node.setAttribute('data-compression-started-at',String(started));
|
||
node.setAttribute('data-compression-message',String(state.message||'Auto-compressing context...'));
|
||
_startCompressionElapsedTimer();
|
||
}
|
||
const existing=inner.querySelector('[data-live-compression-card="1"]');
|
||
if(existing) existing.replaceWith(node);
|
||
else inner.appendChild(node);
|
||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||
return true;
|
||
}
|
||
function _isHandoffSummaryToolPayload(value){
|
||
if(!value||typeof value!=='object'||Array.isArray(value)) return false;
|
||
return value._handoff_summary_card === true;
|
||
}
|
||
function _parseHandoffSummaryPayload(content){
|
||
if(!content) return null;
|
||
if(typeof content==='object' && !Array.isArray(content)) return _isHandoffSummaryToolPayload(content)?content:null;
|
||
if(typeof content!=='string') return null;
|
||
try {
|
||
const parsed=JSON.parse(content);
|
||
return _isHandoffSummaryToolPayload(parsed)?parsed:null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
function _handoffSummaryStateFromMessage(m){
|
||
if(!m||m.role!=='tool') return null;
|
||
const payload = _parseHandoffSummaryPayload(m.content);
|
||
if(!payload) return null;
|
||
if(String(payload.session_id||'') && S.session && String(m.session_id||'') && String(payload.session_id)!==String(S.session.session_id||'')) {
|
||
return null;
|
||
}
|
||
const summary = String(payload.summary||'').trim();
|
||
if(!summary) return null;
|
||
return {
|
||
phase: 'done',
|
||
channel: payload.channel || null,
|
||
rounds: Number.isFinite(payload.rounds)?payload.rounds:null,
|
||
summary,
|
||
fallback: !!payload.fallback,
|
||
generatedAt: Number(payload.generated_at) || null,
|
||
};
|
||
}
|
||
function _collectHandoffSummaryStates(messages){
|
||
const states=[];
|
||
if(!Array.isArray(messages)) return states;
|
||
for(let i=0;i<messages.length;i++){
|
||
const state=_handoffSummaryStateFromMessage(messages[i]);
|
||
if(state) states.push({state, rawIdx:i});
|
||
}
|
||
return states;
|
||
}
|
||
function _isContextCompactionMessage(m){
|
||
if(!m||!m.role||m.role==='tool') return false;
|
||
const text=msgContent(m)||String(m.content||'');
|
||
return /^\s*\[context compaction/i.test(text) || /^\s*context compaction/i.test(text);
|
||
}
|
||
function _isPreservedCompressionTaskListMarkerText(text){
|
||
return /^\s*\[your active task list was preserved across context compression\]/i.test(String(text||''));
|
||
}
|
||
function _isPreservedCompressionTaskListMarkerOnlyText(text){
|
||
return _isPreservedCompressionTaskListMarkerText(text)
|
||
&& !String(text||'')
|
||
.replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'')
|
||
.trim();
|
||
}
|
||
function _isPreservedCompressionTaskListMessage(m){
|
||
if(!m||m.role!=='user') return false;
|
||
const text=msgContent(m)||String(m.content||'');
|
||
return /^\s*\[your active task list was preserved across context compression\]/i.test(text);
|
||
}
|
||
function _isMarkerOnlyAssistantCompressionMessage(m){
|
||
if(!m||m.role!=='assistant') return false;
|
||
const text=msgContent(m)||String(m.content||'');
|
||
return _isPreservedCompressionTaskListMarkerOnlyText(text);
|
||
}
|
||
function _preservedCompressionTaskListPreview(text){
|
||
const body=String(text||'')
|
||
.replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'')
|
||
.trim();
|
||
return (body.split(/\n+/).map(line=>line.trim()).filter(Boolean).slice(0,2).join(' ') || t('preserved_task_list_label'));
|
||
}
|
||
function _compressionMessageAnchorKey(m){
|
||
if(!m||!m.role||m.role==='tool') return null;
|
||
let content='';
|
||
try{
|
||
content=String(msgContent(m)||'');
|
||
}catch(_){
|
||
content=String(m.content||'');
|
||
}
|
||
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
|
||
const ts=m._ts||m.timestamp||null;
|
||
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
|
||
if(!norm && !attachments && !ts) return null;
|
||
return {role:String(m.role||''), ts, text:norm, attachments};
|
||
}
|
||
function _compressionAnchorIndex(visWithIdx, anchorKey, fallbackIdx=null){
|
||
if(anchorKey&&Array.isArray(visWithIdx)){
|
||
for(let i=visWithIdx.length-1;i>=0;i--){
|
||
const candidate=_compressionMessageAnchorKey(visWithIdx[i].m);
|
||
if(!candidate) continue;
|
||
const anchorTs=String(anchorKey.ts??'');
|
||
const candidateTs=String(candidate.ts??'');
|
||
if(
|
||
candidate.role===String(anchorKey.role||'') &&
|
||
(!anchorTs||!candidateTs||candidateTs===anchorTs) &&
|
||
String(candidate.text||'')===String(anchorKey.text||'') &&
|
||
Number(candidate.attachments||0)===Number(anchorKey.attachments||0)
|
||
){
|
||
return i;
|
||
}
|
||
}
|
||
}
|
||
return typeof fallbackIdx==='number' ? fallbackIdx : null;
|
||
}
|
||
function _latestCompressionReferenceMessage(messages, summaryText=''){
|
||
if(!Array.isArray(messages)||!messages.length) return {message:null, rawIdx:-1};
|
||
const summaryNorm=String(summaryText||'').replace(/\s+/g,' ').trim();
|
||
for(let i=messages.length-1;i>=0;i--){
|
||
const m=messages[i];
|
||
if(!_isContextCompactionMessage(m)) continue;
|
||
if(!summaryNorm) return {message:m, rawIdx:i};
|
||
let content='';
|
||
try{
|
||
content=String(msgContent(m)||'');
|
||
}catch(_){
|
||
content=String((m&&m.content)||'');
|
||
}
|
||
const contentNorm=content.replace(/\s+/g,' ').trim();
|
||
if(contentNorm.includes(summaryNorm)) return {message:m, rawIdx:i};
|
||
}
|
||
return {message:null, rawIdx:-1};
|
||
}
|
||
function _compressionReferenceCardHtml(text, open=false){
|
||
const preview=text.split(/\n+/).filter(Boolean).slice(0,2).join(' ');
|
||
return `
|
||
<div class="tool-card-row compression-card-row" data-compression-card="1" data-raw-text="${esc(text)}">
|
||
<div class="tool-card tool-card-compress-reference${open?' open':''}">
|
||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||
<span class="tool-card-icon">${li('star',13)}</span>
|
||
<span class="tool-card-name">${esc(t('context_compaction_label'))}</span>
|
||
<span class="tool-card-preview">${esc(t('reference_only_label'))} · ${esc(preview)}</span>
|
||
<span class="tool-card-toggle">${li('chevron-right',12)}</span>
|
||
<button class="msg-copy-btn msg-action-btn tool-card-copy compression-reference-copy" title="${t('copy')}" onclick="copyMsg(this);event.stopPropagation()">${li('copy',13)}</button>
|
||
</div>
|
||
<div class="tool-card-detail">
|
||
<div class="tool-card-result">
|
||
<pre>${esc(text)}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>`;
|
||
}
|
||
function _preservedCompressionTaskListCardHtml(m, open=false){
|
||
const text=msgContent(m)||String(m.content||'');
|
||
return `
|
||
<div class="tool-card-row compression-card-row" data-compression-card="1" data-raw-text="${esc(text)}">
|
||
${_compressionStatusCardHtml({
|
||
statusLabel: t('preserved_task_list_label'),
|
||
previewText: _preservedCompressionTaskListPreview(text),
|
||
detail: text,
|
||
icon: li('list-todo',13),
|
||
open,
|
||
variantClass: 'tool-card-compress-reference',
|
||
})}
|
||
</div>`;
|
||
}
|
||
function _preservedCompressionTaskListCardsHtml(messages){
|
||
return (messages||[]).map(m=>_preservedCompressionTaskListCardHtml(m, false)).join('');
|
||
}
|
||
function _latestTodoToolItems(messages){
|
||
for(let i=(messages||[]).length-1;i>=0;i--){
|
||
const m=messages[i];
|
||
if(!m||m.role!=='tool') continue;
|
||
try{
|
||
const payload=typeof m.content==='string'?JSON.parse(m.content):m.content;
|
||
if(payload&&Array.isArray(payload.todos)) return payload.todos;
|
||
}catch(_){ }
|
||
}
|
||
return null;
|
||
}
|
||
function _hasActiveTodoItems(items){
|
||
return Array.isArray(items) && items.some(item=>{
|
||
const status=String(item&&item.status||'').trim().toLowerCase();
|
||
return status==='pending'||status==='in_progress';
|
||
});
|
||
}
|
||
function _latestPreservedCompressionTaskListMessages(messages){
|
||
const latest=[...(messages||[])].reverse().find(m=>_isPreservedCompressionTaskListMessage(m));
|
||
if(!latest) return [];
|
||
const latestTodos=_latestTodoToolItems(messages);
|
||
if(Array.isArray(latestTodos) && !_hasActiveTodoItems(latestTodos)) return [];
|
||
return [latest];
|
||
}
|
||
function _isSameLocalDay(dateA, dateB){
|
||
return dateA.getFullYear()===dateB.getFullYear()
|
||
&& dateA.getMonth()===dateB.getMonth()
|
||
&& dateA.getDate()===dateB.getDate();
|
||
}
|
||
function _formatMessageFooterTimestamp(tsVal){
|
||
if(!tsVal) return '';
|
||
const date=new Date(tsVal*1000);
|
||
const now=new Date();
|
||
// Use _formatInServerTz when available — it correctly handles fractional-hour
|
||
// offsets like India +0530 that Etc/GMT cannot express. Falls back to plain
|
||
// toLocaleString when sessions.js hasn't loaded yet.
|
||
const fmt=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
|
||
if(_isSameLocalDay(date, now)){
|
||
const opts={hour:'2-digit', minute:'2-digit'};
|
||
return fmt?fmt(date,opts):date.toLocaleTimeString([], opts);
|
||
}
|
||
const opts={month:'short', day:'numeric', hour:'numeric', minute:'2-digit'};
|
||
return fmt?fmt(date,opts):date.toLocaleString([], opts);
|
||
}
|
||
function _compressionStatusCardHtml({
|
||
statusLabel,
|
||
previewText,
|
||
detail,
|
||
icon,
|
||
open=false,
|
||
variantClass='',
|
||
}){
|
||
const statusDetail = String(detail || '').trim();
|
||
const hasBody = !!statusDetail;
|
||
const openClass = open ? ' open' : '';
|
||
const statusIcon = icon;
|
||
const bodyHtml = hasBody ? `<div class="tool-card-detail"><div class="tool-card-result"><pre>${esc(statusDetail)}</pre></div></div>` : '';
|
||
const toggleHtml = hasBody ? `<span class="tool-card-toggle">${li('chevron-right',12)}</span>` : '';
|
||
return `
|
||
<div class="tool-card ${variantClass}${openClass}">
|
||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||
${statusIcon}
|
||
<span class="tool-card-name">${esc(statusLabel)}</span>
|
||
<span class="tool-card-preview">${esc(previewText)}</span>
|
||
${toggleHtml}
|
||
</div>
|
||
${bodyHtml}
|
||
</div>`;
|
||
}
|
||
function _handoffStateForCurrentSession(){
|
||
const state=window._handoffUi;
|
||
if(!state||!S.session||state.sessionId!==S.session.session_id) return null;
|
||
return state;
|
||
}
|
||
function clearHandoffUi(){
|
||
window._handoffUi=null;
|
||
renderMessages();
|
||
}
|
||
function setHandoffUi(state){
|
||
if(!state){
|
||
clearHandoffUi();
|
||
return;
|
||
}
|
||
window._handoffUi={...state};
|
||
renderMessages();
|
||
}
|
||
function _handoffCardsHtml(state){
|
||
if(!state) return '';
|
||
const channel=String(state.channel||'').trim();
|
||
const label=channel?`${channel} handoff summary`:'Handoff summary';
|
||
const isError=state.phase==='error';
|
||
const isDone=state.phase==='done';
|
||
const isFallback=!!state.fallback;
|
||
const detail=isError
|
||
? String(state.errorText||'Could not generate summary. Please try again.')
|
||
: isDone
|
||
? String(state.summary||'')
|
||
: 'Generating handoff summary...';
|
||
const meta=typeof state.rounds==='number'
|
||
? `${state.rounds} external conversation rounds`
|
||
: '';
|
||
const icon=isError
|
||
? li('x',13)
|
||
: isDone
|
||
? li('check',13)
|
||
: '<span class="tool-card-running-dot"></span>';
|
||
const bodyHtml=isDone&&!isError
|
||
? (
|
||
`${renderMd(detail)}${
|
||
isFallback
|
||
? '<p class="handoff-summary-fallback-note">Fallback summary generated from recent turns; no model-based rewrite was used.</p>'
|
||
: ''
|
||
}`
|
||
)
|
||
: `<p>${esc(detail)}</p>`;
|
||
return `
|
||
<div class="tool-card-row compression-card-row handoff-card-row" data-compression-card="1" data-handoff-card="1">
|
||
<div class="tool-card tool-card-handoff-summary${isError?' tool-card-compress-error':''} open">
|
||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||
${icon}
|
||
<span class="tool-card-name">${esc(label)}</span>
|
||
${meta?`<span class="tool-card-preview">${esc(meta)}</span>`:''}
|
||
<span class="tool-card-toggle">${li('chevron-right',12)}</span>
|
||
</div>
|
||
<div class="tool-card-detail">
|
||
<div class="tool-card-result handoff-summary-body">${bodyHtml}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function _handoffCardsNode(state){
|
||
const wrap=document.createElement('div');
|
||
wrap.className='compression-turn handoff-turn';
|
||
wrap.innerHTML=`<div class="compression-turn-blocks">${_handoffCardsHtml(state)}</div>`;
|
||
return wrap;
|
||
}
|
||
function _contextCompactionMessageHtml(m, tsTitle='', preservedMessages=[]){
|
||
const text=msgContent(m)||String(m.content||'');
|
||
return `<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(text, false, tsTitle)}${_preservedCompressionTaskListCardsHtml(preservedMessages)}</div></div>`;
|
||
}
|
||
function renderCompressionUi(){
|
||
const el=$('liveCompressionCards');
|
||
if(!el) return;
|
||
el.innerHTML='';
|
||
el.style.display='none';
|
||
}
|
||
// Session render cache: avoids full markdown+DOM rebuild when switching back
|
||
// to a session that was already rendered with the same message count.
|
||
// Keyed by session_id. Only used on cross-session navigation, never for
|
||
// in-session updates (new messages, edits, stream events).
|
||
//
|
||
// Known limitation: cache key is session_id + message count. Edits and retries
|
||
// that mutate message content without changing the count will serve stale HTML
|
||
// on back-navigation until the user triggers an in-session update. Acceptable
|
||
// for the common read-only back-navigation case; not suitable as a general cache.
|
||
const _sessionHtmlCache=new Map();
|
||
let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
|
||
function clearMessageRenderCache(){
|
||
_sessionHtmlCache.clear();
|
||
_sessionHtmlCacheSid=null;
|
||
}
|
||
|
||
function _clipCliToolSnippet(text, maxLen=20000){
|
||
const s=String(text||'');
|
||
if(s.length<=maxLen) return s;
|
||
return `${s.slice(0,maxLen)}\n\n... truncated ${s.length-maxLen} chars ...`;
|
||
}
|
||
|
||
function _cliToolResultText(raw){
|
||
const s=String(raw||'');
|
||
try{
|
||
const rd=JSON.parse(s);
|
||
if(rd && typeof rd==='object'){
|
||
for(const key of ['output','result','error','content','diff','patch']){
|
||
if(Object.prototype.hasOwnProperty.call(rd,key)){
|
||
const v=rd[key];
|
||
if(v==null) return '';
|
||
return typeof v==='string' ? v : JSON.stringify(v,null,2);
|
||
}
|
||
}
|
||
}
|
||
}catch(e){}
|
||
return s;
|
||
}
|
||
|
||
function _cliLooksLikePatchDiff(text){
|
||
const s=String(text||'');
|
||
if(!s) return false;
|
||
if(/\*\*\* Begin Patch/.test(s)) return true;
|
||
if(/^diff --git /m.test(s)) return true;
|
||
if(/^@@\s/m.test(s)) return true;
|
||
if(/(^|\n)---\s+/.test(s) && /(^|\n)\+\+\+\s+/.test(s)) return true;
|
||
return false;
|
||
}
|
||
|
||
function _cliToolResultSnippet(raw){
|
||
const fullText=_cliToolResultText(raw);
|
||
if(_cliLooksLikePatchDiff(fullText)) return _clipCliToolSnippet(fullText);
|
||
return String(fullText||'').slice(0,200);
|
||
}
|
||
|
||
function _prefixedCliDiffLines(prefix, value){
|
||
return String(value||'').split('\n').map(line=>`${prefix}${line}`).join('\n');
|
||
}
|
||
|
||
function _firstOwnedValue(obj, keys){
|
||
for(const key of keys){
|
||
if(obj && Object.prototype.hasOwnProperty.call(obj,key)) return obj[key];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function _cliPatchSnippetFromArgs(name, args){
|
||
if(!args || typeof args!=='object') return '';
|
||
const toolName=String(name||'').toLowerCase();
|
||
for(const key of ['patch','diff']){
|
||
const v=args[key];
|
||
if(typeof v==='string' && v.trim()) return _clipCliToolSnippet(v);
|
||
}
|
||
for(const key of ['input','content']){
|
||
const v=args[key];
|
||
if(typeof v==='string' && _cliLooksLikePatchDiff(v)) return _clipCliToolSnippet(v);
|
||
}
|
||
const isEditLike=toolName==='apply_patch'
|
||
|| toolName==='patch'
|
||
|| toolName.includes('edit')
|
||
|| toolName==='replace'
|
||
|| toolName==='str_replace';
|
||
if(!isEditLike) return '';
|
||
const oldValue=_firstOwnedValue(args,['old_string','old_str','old','before']);
|
||
const newValue=_firstOwnedValue(args,['new_string','new_str','new','after']);
|
||
if(oldValue!==undefined || newValue!==undefined){
|
||
const path=String(_firstOwnedValue(args,['file_path','path','filename'])||'');
|
||
const lines=[];
|
||
if(path) lines.push(path);
|
||
if(oldValue!==undefined) lines.push(_prefixedCliDiffLines('-', oldValue));
|
||
if(newValue!==undefined) lines.push(_prefixedCliDiffLines('+', newValue));
|
||
return _clipCliToolSnippet(lines.join('\n'));
|
||
}
|
||
if(Array.isArray(args.edits)){
|
||
const path=String(_firstOwnedValue(args,['file_path','path','filename'])||'');
|
||
const chunks=[];
|
||
if(path) chunks.push(path);
|
||
args.edits.slice(0,5).forEach(edit=>{
|
||
if(!edit || typeof edit!=='object') return;
|
||
const before=_firstOwnedValue(edit,['old_string','old_str','old','before']);
|
||
const after=_firstOwnedValue(edit,['new_string','new_str','new','after']);
|
||
if(before!==undefined) chunks.push(_prefixedCliDiffLines('-', before));
|
||
if(after!==undefined) chunks.push(_prefixedCliDiffLines('+', after));
|
||
});
|
||
if(chunks.length) return _clipCliToolSnippet(chunks.join('\n'));
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function _cliToolCardSnippet(resultSnippet, patchSnippet){
|
||
if(_cliLooksLikePatchDiff(resultSnippet)) return resultSnippet;
|
||
if(!patchSnippet) return resultSnippet || '';
|
||
const result=String(resultSnippet||'').trim();
|
||
if(!result) return patchSnippet;
|
||
const generic=/^(success|ok|done|done\.|exit code: 0)$/i.test(result);
|
||
if(generic) return patchSnippet;
|
||
return `${resultSnippet}\n\n${patchSnippet}`;
|
||
}
|
||
|
||
function _cliToolCardHasDiffSnippet(resultSnippet, patchSnippet){
|
||
return !!patchSnippet || _cliLooksLikePatchDiff(resultSnippet);
|
||
}
|
||
|
||
function _captureMessageScrollSnapshot(){
|
||
const el=$('messages');
|
||
if(!el) return null;
|
||
return {top:el.scrollTop};
|
||
}
|
||
function _restoreMessageScrollSnapshot(snapshot){
|
||
const el=$('messages');
|
||
if(!el||!snapshot) return;
|
||
const maxTop=Math.max(0,el.scrollHeight-el.clientHeight);
|
||
_programmaticScroll=true;
|
||
el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
|
||
_lastScrollTop=el.scrollTop;
|
||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||
}
|
||
function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
|
||
// Terminal stream renders can happen after S.activeStreamId is cleared.
|
||
// In that case, preserveScroll asks the normal pin-state helper to decide:
|
||
// pinned users stay at bottom; users who manually scrolled up get their
|
||
// pre-render scrollTop restored after the DOM replacement.
|
||
if(preserveScroll){
|
||
if(_scrollPinned) scrollIfPinned();
|
||
else _restoreMessageScrollSnapshot(scrollSnapshot);
|
||
return;
|
||
}
|
||
if(S.activeStreamId){
|
||
scrollIfPinned();
|
||
return;
|
||
}
|
||
scrollToBottom();
|
||
}
|
||
|
||
function renderMessages(options){
|
||
const preserveScroll=!!(options&&options.preserveScroll);
|
||
const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null;
|
||
const inner=$('msgInner');
|
||
const sid=S.session?S.session.session_id:null;
|
||
const msgCount=S.messages.length;
|
||
if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid);
|
||
const renderWindowSize=_currentMessageRenderWindowSize();
|
||
const hasTransientTranscriptUi=!!(
|
||
(window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) ||
|
||
(window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid))
|
||
);
|
||
|
||
// Fast path: switching back to a previously rendered session with same count.
|
||
// Guard: sid !== _sessionHtmlCacheSid ensures in-session updates (edits,
|
||
// new messages, tool_complete) always get a fresh rebuild.
|
||
// Skip cache if this session is still streaming — the live smd parser writes
|
||
// into a DOM node inside the cached subtree; serving cached HTML detaches it.
|
||
// Also skip cache for transient transcript cards such as /compress and
|
||
// cross-channel handoff summaries; otherwise the cached transcript returns
|
||
// before those cards can be inserted.
|
||
if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
|
||
const cached=_sessionHtmlCache.get(sid);
|
||
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){
|
||
inner.innerHTML=cached.html;
|
||
_sessionHtmlCacheSid=sid;
|
||
_wireMessageWindowLoadEarlierButton();
|
||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
|
||
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
|
||
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
|
||
return;
|
||
}
|
||
}
|
||
|
||
const compressionState=_compressionStateForCurrentSession();
|
||
if(window._compressionUi && !compressionState) clearCompressionUi();
|
||
const handoffState=_handoffStateForCurrentSession();
|
||
if(window._handoffUi && !handoffState) window._handoffUi=null;
|
||
const sessionCompressionAnchor=(
|
||
S.session && typeof S.session.compression_anchor_visible_idx==='number'
|
||
) ? S.session.compression_anchor_visible_idx : null;
|
||
const sessionCompressionAnchorKey=(
|
||
S.session && S.session.compression_anchor_message_key && typeof S.session.compression_anchor_message_key==='object'
|
||
) ? S.session.compression_anchor_message_key : null;
|
||
const sessionCompressionSummary=(
|
||
S.session && typeof S.session.compression_anchor_summary==='string'
|
||
) ? S.session.compression_anchor_summary.trim() : '';
|
||
const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);
|
||
const vis=S.messages.filter(m=>{
|
||
if(!m||!m.role||m.role==='tool')return false;
|
||
if(_isContextCompactionMessage(m)) return false;
|
||
if(_isPreservedCompressionTaskListMessage(m)) return false;
|
||
if(m.role==='assistant'){
|
||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||
if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
|
||
}
|
||
return m._statusCard||msgContent(m)||m.attachments?.length;
|
||
});
|
||
$('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':'';
|
||
inner.innerHTML='';
|
||
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
|
||
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
|
||
S.messages,
|
||
sessionCompressionSummary
|
||
);
|
||
const referenceText=referenceMessage
|
||
? msgContent(referenceMessage)||String(referenceMessage.content||'')
|
||
: sessionCompressionSummary;
|
||
const referenceNode=(!compressionState && !!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary))
|
||
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
|
||
: null;
|
||
let preservedCompressionTaskCardsAttached=!!referenceNode;
|
||
const visWithIdx=[];
|
||
const preservedCompressionRawIdxs=[];
|
||
let rawIdx=0;
|
||
for(const m of S.messages){
|
||
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
|
||
if(_isPreservedCompressionTaskListMessage(m)){preservedCompressionRawIdxs.push(rawIdx);rawIdx++;continue;}
|
||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||
if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
|
||
rawIdx++;
|
||
}
|
||
// Show a top affordance when earlier transcript content exists either in
|
||
// memory (DOM windowing) or on the server (paginated session fetch).
|
||
// Prefer expanding the local render window first so a fully loaded long
|
||
// session can reduce DOM nodes without losing in-memory transcript data.
|
||
const windowStart=Math.max(0, visWithIdx.length-renderWindowSize);
|
||
const hiddenBeforeCount=windowStart;
|
||
const renderVisWithIdx=visWithIdx.slice(windowStart);
|
||
const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity;
|
||
const hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0);
|
||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||
if(hiddenBeforeCount>0 || hasServerOlder){
|
||
const indicator=document.createElement('button');
|
||
indicator.type='button';
|
||
indicator.id='loadOlderIndicator';
|
||
indicator.className='load-older-indicator message-window-load-earlier';
|
||
indicator.textContent=hiddenBeforeCount>0
|
||
? `Load earlier messages (${hiddenBeforeCount} hidden)`
|
||
: (typeof t==='function'?t('load_older_messages'):'Load earlier messages');
|
||
indicator.onclick=()=>{
|
||
if(hiddenBeforeCount>0) _showEarlierRenderedMessages();
|
||
else if(typeof _loadOlderMessages==='function') _loadOlderMessages();
|
||
};
|
||
inner.appendChild(indicator);
|
||
_wireMessageWindowLoadEarlierButton();
|
||
}
|
||
let lastUserRawIdx=-1;
|
||
for(let i=visWithIdx.length-1;i>=0;i--){
|
||
if(visWithIdx[i].m&&visWithIdx[i].m.role==='user'){
|
||
lastUserRawIdx=visWithIdx[i].rawIdx;
|
||
break;
|
||
}
|
||
}
|
||
const insertionAnchorFull=_compressionAnchorIndex(
|
||
visWithIdx,
|
||
compressionState ? compressionState.anchorMessageKey : sessionCompressionAnchorKey,
|
||
compressionState
|
||
? (typeof compressionState.anchorVisibleIdx==='number' ? compressionState.anchorVisibleIdx : compressionState.anchorRawIdx)
|
||
: sessionCompressionAnchor
|
||
);
|
||
let insertionAnchor=null;
|
||
if(typeof insertionAnchorFull==='number'){
|
||
if(insertionAnchorFull<windowStart) insertionAnchor=renderVisWithIdx.length?0:null;
|
||
else if(insertionAnchorFull<windowStart+renderVisWithIdx.length) insertionAnchor=insertionAnchorFull-windowStart;
|
||
else insertionAnchor=renderVisWithIdx.length?renderVisWithIdx.length-1:null;
|
||
}
|
||
let _prevSepKey=null;
|
||
let currentAssistantTurn=null;
|
||
const questionRawIdxByAssistantRawIdx=new Map();
|
||
let lastQuestionRawIdx=-1;
|
||
for(const entry of visWithIdx){
|
||
const role=entry&&entry.m&&entry.m.role;
|
||
if(role==='user') lastQuestionRawIdx=entry.rawIdx;
|
||
else if(role==='assistant') questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx);
|
||
}
|
||
const assistantSegments=new Map();
|
||
const assistantThinking=new Map();
|
||
const userRows=new Map();
|
||
// Windowed render loop replaces the legacy full loop:
|
||
// for(let vi=0;vi<visWithIdx.length;vi++)
|
||
for(let vi=0;vi<renderVisWithIdx.length;vi++){
|
||
const {m,rawIdx}=renderVisWithIdx[vi];
|
||
const _tsSep=m._ts||m.timestamp;
|
||
if(_tsSep){
|
||
const _d=new Date(_tsSep*1000);
|
||
const _key=_d.toDateString();
|
||
if(_prevSepKey && _prevSepKey!==_key){
|
||
const sep=document.createElement('div');
|
||
sep.className='msg-date-sep';
|
||
sep.textContent=_fmtDateSep(_d);
|
||
inner.appendChild(sep);
|
||
}
|
||
_prevSepKey=_key;
|
||
}
|
||
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*<think>([\s\S]*?)<\/think>\s*/);
|
||
if(thinkMatch){
|
||
thinkingText=thinkMatch[1].trim();
|
||
content=content.replace(/^\s*<think>[\s\S]*?<\/think>\s*/,'').trimStart();
|
||
}
|
||
if(!thinkingText){
|
||
// Historical name "gemmaMatch" refers to MiniMax <|channel>thought format.
|
||
const gemmaMatch=content.match(/^\s*<\|channel\|?>thought\n?([\s\S]*?)<channel\|>\s*/);
|
||
if(gemmaMatch){
|
||
thinkingText=gemmaMatch[1].trim();
|
||
content=content.replace(/^\s*<\|channel\|?>thought\n?[\s\S]*?<channel\|>\s*/,'').trimStart();
|
||
}
|
||
}
|
||
if(!thinkingText){
|
||
// Gemma 4 uses asymmetric <|turn|>thinking\n...<turn|> delimiters.
|
||
const gemmaTurnMatch=content.match(/^\s*<\|turn\|>thinking\n([\s\S]*?)<turn\|>\s*/);
|
||
if(gemmaTurnMatch){
|
||
thinkingText=gemmaTurnMatch[1].trim();
|
||
content=content.replace(/^\s*<\|turn\|>thinking\n[\s\S]*?<turn\|>\s*/,'').trimStart();
|
||
}
|
||
}
|
||
}
|
||
const isUser=m.role==='user';
|
||
if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){
|
||
content='**Error:** No response received after context compression. Please retry.';
|
||
}
|
||
const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content;
|
||
if(thinkingText&&!isUser){
|
||
thinkingText=_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent);
|
||
}
|
||
const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1;
|
||
const nextRendered=renderVisWithIdx[vi+1];
|
||
const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant');
|
||
let filesHtml='';
|
||
if(m.attachments&&m.attachments.length){
|
||
// Static regression tests intentionally look for msg-media-img/msg-file-badge near this branch.
|
||
const _attachSid=(S.session&&S.session.session_id)||'';
|
||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>{
|
||
const fLabel=typeof f==='string'?f:(f&&(f.name||f.filename||f.path))||'';
|
||
const fname=String(fLabel).split('/').pop()||String(fLabel);
|
||
// Use api/file/raw which resolves filename relative to the session workspace.
|
||
const fileUrl='api/file/raw?session_id='+encodeURIComponent(_attachSid)+'&path='+encodeURIComponent(fname);
|
||
return _renderAttachmentHtml(fname,fileUrl);
|
||
}).join('')}</div>`;
|
||
}
|
||
let bodyHtml = isUser ? _renderUserFencedBlocks(displayContent) : renderMd(_stripXmlToolCallsDisplay(String(displayContent)));
|
||
if(!isUser&&m.provider_details){
|
||
const summary=m.provider_details_label||'Provider details';
|
||
bodyHtml += `<details class="provider-error-details"><summary>${esc(String(summary))}</summary><pre><code>${esc(String(m.provider_details))}</code></pre></details>`;
|
||
}
|
||
const statusHtml = (!isUser&&m._statusCard) ? _statusCardHtml(m._statusCard) : '';
|
||
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
|
||
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
|
||
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo',13)}</button>` : '';
|
||
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
|
||
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
|
||
const forkBtn = `<button class="msg-action-btn" title="${t('fork_from_here')}" onclick="forkFromMessage(${rawIdx+1})">${li('git-branch',13)}</button>`;
|
||
const ttsBtn = !isUser ? `<button class="msg-action-btn msg-tts-btn" title="${t('tts_listen')||'Listen'}" onclick="speakMessage(this)">${li('volume-2',13)}</button>` : '';
|
||
const tsVal=m._ts||m.timestamp;
|
||
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)
|
||
// correctly via offset arithmetic; bare toLocaleString is the browser-tz fallback.
|
||
const _fmtSv=(typeof _formatInServerTz==='function')?_formatInServerTz:null;
|
||
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
|
||
const tsTime=_formatMessageFooterTimestamp(tsVal);
|
||
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
|
||
const questionJumpBtn = (!isUser&&!m._live&&isTurnFinalAssistant)
|
||
? _questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))
|
||
: '';
|
||
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;
|
||
|
||
if(_isContextCompactionMessage(m)){
|
||
if(compressionState || referenceNode){
|
||
continue;
|
||
}else{
|
||
currentAssistantTurn=null;
|
||
const row=document.createElement('div');
|
||
const preservedForThisCard=preservedCompressionTaskCardsAttached?[]:preservedCompressionTaskMessages;
|
||
row.innerHTML=_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard);
|
||
if(preservedForThisCard.length) preservedCompressionTaskCardsAttached=true;
|
||
inner.appendChild(row.firstElementChild);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if(isUser){
|
||
currentAssistantTurn=null;
|
||
const row=document.createElement('div');
|
||
row.className='msg-row';
|
||
row.id=_userMessageDomId(rawIdx);
|
||
row.dataset.msgIdx=rawIdx;
|
||
row.dataset.role='user';
|
||
row.dataset.rawText=String(displayContent).trim();
|
||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||
inner.appendChild(row);
|
||
userRows.set(rawIdx, row);
|
||
continue;
|
||
}
|
||
|
||
if(!currentAssistantTurn){
|
||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||
inner.appendChild(currentAssistantTurn);
|
||
}
|
||
const seg=document.createElement('div');
|
||
seg.className='assistant-segment';
|
||
seg.dataset.msgIdx=rawIdx;
|
||
seg.dataset.rawText=String(content).trim();
|
||
if(m._live){
|
||
currentAssistantTurn.id='liveAssistantTurn';
|
||
// Stamp the session id on the live turn so finalizeThinkingCard()
|
||
// and other late callbacks can verify they're operating on the
|
||
// right session's DOM (the user may have switched tabs/sessions
|
||
// while this stream is still streaming). See #1366.
|
||
if(S.session) currentAssistantTurn.dataset.sessionId=S.session.session_id;
|
||
seg.setAttribute('data-live-assistant','1');
|
||
}
|
||
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
|
||
if(thinkingText&&window._showThinking!==false){
|
||
if(isSimplifiedToolCalling()) assistantThinking.set(rawIdx, thinkingText);
|
||
else if(window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
|
||
}
|
||
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||statusHtml);
|
||
if(statusHtml){
|
||
seg.insertAdjacentHTML('beforeend', statusHtml);
|
||
}else if(hasVisibleBody){
|
||
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
|
||
seg.classList.add('assistant-segment-anchor');
|
||
}
|
||
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
|
||
assistantSegments.set(rawIdx, seg);
|
||
}
|
||
|
||
function _insertCompressionLikeNode(node, anchorIndex){
|
||
if(!node) return;
|
||
const anchorIdx=anchorIndex===undefined?insertionAnchor:anchorIndex;
|
||
if(anchorIdx!==null && renderVisWithIdx[anchorIdx]){
|
||
const anchorRawIdx=renderVisWithIdx[anchorIdx].rawIdx;
|
||
const anchorSeg=assistantSegments.get(anchorRawIdx);
|
||
if(anchorSeg){
|
||
const turn=anchorSeg.closest('.assistant-turn');
|
||
const blocks=_assistantTurnBlocks(turn);
|
||
if(blocks){
|
||
blocks.appendChild(node);
|
||
return;
|
||
}
|
||
}
|
||
const userRow=userRows.get(anchorRawIdx);
|
||
if(userRow && userRow.parentElement){
|
||
userRow.parentElement.insertBefore(node, userRow.nextSibling);
|
||
return;
|
||
}
|
||
}
|
||
inner.appendChild(node);
|
||
}
|
||
function _insertCompressionLikeNodeByRawIdx(node, rawIdx){
|
||
if(!node) return;
|
||
if(!renderVisWithIdx.length){
|
||
inner.appendChild(node);
|
||
return;
|
||
}
|
||
let anchorIdx=null;
|
||
for(let i=0;i<renderVisWithIdx.length;i++){
|
||
if(renderVisWithIdx[i].rawIdx > rawIdx){
|
||
anchorIdx=i;
|
||
break;
|
||
}
|
||
}
|
||
if(anchorIdx===null){
|
||
inner.appendChild(node);
|
||
return;
|
||
}
|
||
const anchorRawIdx=renderVisWithIdx[anchorIdx].rawIdx;
|
||
const anchorSeg=assistantSegments.get(anchorRawIdx);
|
||
if(anchorSeg){
|
||
const turn=anchorSeg.closest('.assistant-turn');
|
||
const blocks=_assistantTurnBlocks(turn);
|
||
if(blocks){
|
||
blocks.insertBefore(node, anchorSeg);
|
||
return;
|
||
}
|
||
const turnParent=turn && turn.parentElement;
|
||
if(turnParent){
|
||
turnParent.insertBefore(node, turn);
|
||
return;
|
||
}
|
||
}
|
||
const userRow=userRows.get(anchorRawIdx);
|
||
if(userRow && userRow.parentElement){
|
||
userRow.parentElement.insertBefore(node, userRow);
|
||
return;
|
||
}
|
||
inner.appendChild(node);
|
||
}
|
||
const preservedOnlyNode=(!preservedCompressionTaskCardsAttached&&(!referenceMessage||compressionState)&&preservedCompressionTaskMessages.length)
|
||
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
|
||
: null;
|
||
const preservedOnlyAnchor=preservedCompressionRawIdxs.length
|
||
? (()=>{let idx=null;for(let i=0;i<renderVisWithIdx.length;i++){if(renderVisWithIdx[i].rawIdx<preservedCompressionRawIdxs[0]) idx=i;}return idx;})()
|
||
: null;
|
||
const handoffSummaryStates=_collectHandoffSummaryStates(S.messages);
|
||
|
||
_insertCompressionLikeNode(compressionNode);
|
||
if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx);
|
||
else _insertCompressionLikeNode(referenceNode);
|
||
_insertCompressionLikeNode(preservedOnlyNode, preservedOnlyAnchor);
|
||
_insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, renderVisWithIdx.length?renderVisWithIdx.length-1:null);
|
||
for(const entry of handoffSummaryStates){
|
||
if(!entry||!entry.state) continue;
|
||
if(entry.rawIdx<firstRenderedRawIdx) continue;
|
||
_insertCompressionLikeNodeByRawIdx(_handoffCardsNode(entry.state), entry.rawIdx);
|
||
}
|
||
renderCompressionUi();
|
||
// Insert settled tool call cards (history view only).
|
||
// During live streaming, tool cards are rendered in #liveToolCards by the
|
||
// tool SSE handler and never mixed into the message list until done fires.
|
||
//
|
||
// Fallback: if S.toolCalls is empty (sessions that predate session-level tool
|
||
// tracking, or runs that didn't go through the normal streaming path), build
|
||
// a display list from per-message tool_calls (OpenAI format) stored in each
|
||
// assistant message. This covers the reload case described in issue #140.
|
||
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
|
||
// Pass 1: index tool outputs by tool_call_id / tool_use_id so the
|
||
// fallback-built cards carry their result snippet (not just the command).
|
||
// Without this step CLI-origin sessions reload with empty tool cards.
|
||
const resultsByTid={};
|
||
S.messages.forEach(m=>{
|
||
if(!m) return;
|
||
// OpenAI / Hermes CLI format: role=tool with tool_call_id
|
||
if(m.role==='tool'){
|
||
const tid=m.tool_call_id||m.tool_use_id||'';
|
||
if(tid) resultsByTid[tid]=_cliToolResultSnippet(m.content);
|
||
return;
|
||
}
|
||
// Anthropic format: tool_result blocks inside a user message content array
|
||
if(Array.isArray(m.content)){
|
||
m.content.forEach(p=>{
|
||
if(!p||typeof p!=='object'||p.type!=='tool_result') return;
|
||
const tid=p.tool_use_id||'';
|
||
if(!tid) return;
|
||
const raw=typeof p.content==='string'?p.content
|
||
:Array.isArray(p.content)?p.content.map(c=>c&&c.text?c.text:'').join('')
|
||
:'';
|
||
resultsByTid[tid]=_cliToolResultSnippet(raw);
|
||
});
|
||
}
|
||
});
|
||
const derived=[];
|
||
S.messages.forEach((m,rawIdx)=>{
|
||
if(m.role!=='assistant') return;
|
||
// OpenAI format: top-level tool_calls field on the assistant message
|
||
(m.tool_calls||[]).forEach(tc=>{
|
||
if(!tc||typeof tc!=='object') return;
|
||
const fn=tc.function||{};
|
||
const name=fn.name||tc.name||'tool';
|
||
let args={};
|
||
try{ args=JSON.parse(fn.arguments||'{}'); }catch(e){}
|
||
const tid=tc.id||tc.call_id||'';
|
||
const patchSnippet=_cliPatchSnippetFromArgs(name,args);
|
||
const resultSnippet=resultsByTid[tid]||'';
|
||
let argsSnap={};
|
||
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
|
||
derived.push({
|
||
name,
|
||
snippet:_cliToolCardSnippet(resultSnippet,patchSnippet),
|
||
is_diff:_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet),
|
||
tid,
|
||
assistant_msg_idx:rawIdx,
|
||
args:argsSnap,
|
||
done:true,
|
||
});
|
||
});
|
||
// Anthropic format: tool_use blocks inside assistant content array
|
||
if(Array.isArray(m.content)){
|
||
m.content.forEach(p=>{
|
||
if(!p||typeof p!=='object'||p.type!=='tool_use') return;
|
||
const name=p.name||'tool';
|
||
const args=p.input||{};
|
||
const tid=p.id||'';
|
||
const patchSnippet=_cliPatchSnippetFromArgs(name,args);
|
||
const resultSnippet=resultsByTid[tid]||'';
|
||
const argsSnap={};
|
||
if(args && typeof args==='object'){
|
||
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
|
||
}
|
||
derived.push({
|
||
name,
|
||
snippet:_cliToolCardSnippet(resultSnippet,patchSnippet),
|
||
is_diff:_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet),
|
||
tid,
|
||
assistant_msg_idx:rawIdx,
|
||
args:argsSnap,
|
||
done:true,
|
||
});
|
||
});
|
||
}
|
||
});
|
||
if(derived.length) S.toolCalls=derived;
|
||
}
|
||
if(!S.busy){
|
||
inner.querySelectorAll('.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking:not([data-live-thinking="1"])').forEach(el=>el.remove());
|
||
const byAssistant = {};
|
||
for(const tc of (S.toolCalls||[])){
|
||
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
|
||
if(!byAssistant[key]) byAssistant[key] = [];
|
||
byAssistant[key].push(tc);
|
||
}
|
||
const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);
|
||
const anchorInsertAfter = new Map();
|
||
if(isSimplifiedToolCalling()){
|
||
const activityIdxs=[...new Set([...Object.keys(byAssistant).map(k=>parseInt(k)), ...assistantThinking.keys()])].sort((a,b)=>a-b);
|
||
for(const aIdx of activityIdxs){
|
||
const cards=byAssistant[aIdx]||[];
|
||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||
if(!anchorRow&&assistantIdxs.length){
|
||
if(aIdx<assistantIdxs[0]) continue;
|
||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
|
||
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
|
||
}
|
||
if(!anchorRow) continue;
|
||
const anchorParent=anchorRow.parentElement;
|
||
let insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||
const thinkingText=assistantThinking.get(aIdx);
|
||
if(thinkingText){
|
||
const thinkingNode=_thinkingActivityNode(thinkingText, false);
|
||
anchorParent.insertBefore(thinkingNode, anchorRow);
|
||
}
|
||
if(!cards.length) continue;
|
||
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`});
|
||
const sourceMsg=S.messages[aIdx]||{};
|
||
if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration));
|
||
const body=group&&group.querySelector('.tool-call-group-body');
|
||
if(!body) continue;
|
||
for(const tc of cards){
|
||
body.appendChild(buildToolCard(tc));
|
||
}
|
||
_syncToolCallGroupSummary(group);
|
||
if(anchorRow) anchorInsertAfter.set(anchorRow, group);
|
||
}
|
||
}else if(S.toolCalls && S.toolCalls.length){
|
||
for(const [key, cards] of Object.entries(byAssistant)){
|
||
const aIdx = parseInt(key);
|
||
let anchorRow=assistantSegments.get(aIdx)||null;
|
||
if(!anchorRow&&assistantIdxs.length){
|
||
if(aIdx<assistantIdxs[0]) continue;
|
||
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
|
||
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
|
||
}
|
||
if(!anchorRow) continue;
|
||
const anchorParent=anchorRow.parentElement;
|
||
const frag=document.createDocumentFragment();
|
||
let lastInsertedNode=null;
|
||
for(const tc of cards){
|
||
const card=buildToolCard(tc);
|
||
frag.appendChild(card);
|
||
lastInsertedNode=card;
|
||
}
|
||
// Add expand/collapse toggle for groups with 2+ cards
|
||
if(cards.length>=2){
|
||
const toggle=document.createElement('div');
|
||
toggle.className='tool-cards-toggle';
|
||
// Collect card elements before they get moved to DOM
|
||
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
|
||
const expandBtn=document.createElement('button');
|
||
expandBtn.textContent=t('expand_all');
|
||
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
|
||
const collapseBtn=document.createElement('button');
|
||
collapseBtn.textContent=t('collapse_all');
|
||
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
|
||
toggle.appendChild(expandBtn);
|
||
toggle.appendChild(collapseBtn);
|
||
frag.insertBefore(toggle,frag.firstChild);
|
||
}
|
||
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
|
||
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
|
||
if(refNode) anchorParent.insertBefore(frag,refNode);
|
||
else anchorParent.appendChild(frag);
|
||
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
|
||
}
|
||
}
|
||
}
|
||
// Render per-turn duration and optional token usage on assistant messages.
|
||
// Duration stays visible even when token usage is disabled, because it answers
|
||
// the basic "how long did that turn take?" UX question. Only walk rendered
|
||
// assistant segments so hidden messages above the DOM window cannot skew the
|
||
// footer-to-message mapping.
|
||
{
|
||
const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);
|
||
for(const mi of renderedAssistantIdxs){
|
||
const msg=S.messages[mi]||{};
|
||
if(msg.role!=='assistant') continue;
|
||
const routing=msg._gatewayRouting||null;
|
||
const gatewayText=_formatGatewayModelLabel(S.session&&S.session.model||'', '', routing);
|
||
const failoverText=_gatewayRoutingFailoverText(routing);
|
||
const modelWarningText=_gatewayModelWarningText(routing);
|
||
const hasTurnUsage=!!msg._turnUsage;
|
||
const compactActivityForMessage=isSimplifiedToolCalling()&&(
|
||
assistantThinking.has(mi)||
|
||
(S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi)
|
||
);
|
||
const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration);
|
||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
|
||
const seg=assistantSegments.get(mi);
|
||
const row=seg?seg.closest('.assistant-turn'):null;
|
||
const footerRows=row?row.querySelectorAll('.msg-foot'):[];
|
||
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
|
||
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline')) continue;
|
||
const fragments=[];
|
||
if(modelWarningText){
|
||
const warning=document.createElement('span');
|
||
warning.className='msg-model-warning-inline';
|
||
warning.textContent=modelWarningText;
|
||
fragments.push(warning);
|
||
}
|
||
if(failoverText){
|
||
const failover=document.createElement('span');
|
||
failover.className='gateway-failover-inline';
|
||
failover.textContent=failoverText;
|
||
fragments.push(failover);
|
||
}
|
||
if(gatewayText){
|
||
const gateway=document.createElement('span');
|
||
gateway.className='msg-gateway-inline';
|
||
gateway.textContent=gatewayText;
|
||
fragments.push(gateway);
|
||
}
|
||
if(durationText){
|
||
const duration=document.createElement('span');
|
||
duration.className='msg-duration-inline';
|
||
duration.textContent=`Done in ${durationText}`;
|
||
fragments.push(duration);
|
||
}
|
||
if(window._showTokenUsage&&hasTurnUsage){
|
||
const usage=document.createElement('span');
|
||
usage.className='msg-usage-inline';
|
||
const inTok=msg._turnUsage.input_tokens||0;
|
||
const outTok=msg._turnUsage.output_tokens||0;
|
||
const cost=msg._turnUsage.estimated_cost;
|
||
const cacheRead=msg._turnUsage.cache_read_tokens||0;
|
||
const cacheWrite=msg._turnUsage.cache_write_tokens||0;
|
||
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||
const cacheTotal=cacheRead+cacheWrite;
|
||
if(cacheTotal) text+=` · cache ${Math.round((cacheRead/cacheTotal)*100)}% hit`;
|
||
usage.textContent=text;
|
||
fragments.push(usage);
|
||
}
|
||
if(fragments.length){
|
||
targetFoot.classList.add('msg-foot-with-usage');
|
||
for(let i=fragments.length-1;i>=0;i--) targetFoot.insertBefore(fragments[i], targetFoot.firstChild);
|
||
}
|
||
}
|
||
}
|
||
// Only force-scroll when not actively streaming — mid-stream re-renders
|
||
// (tool completion, session switch) must not override the user's scroll position.
|
||
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
|
||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||
// Apply syntax highlighting after DOM is built
|
||
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
|
||
// Refresh todo panel if it's currently open
|
||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||
loadTodos();
|
||
}
|
||
// Apply persisted playback speed after media nodes are rendered.
|
||
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner);
|
||
// Populate session cache so switching back here skips a full rebuild.
|
||
_sessionHtmlCacheSid=sid;
|
||
if(sid&&!hasTransientTranscriptUi){
|
||
const _html=inner.innerHTML;
|
||
// Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions.
|
||
if(_html.length<300_000){
|
||
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize});
|
||
if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);}
|
||
}
|
||
}
|
||
}
|
||
|
||
function _toolDisplayName(tc){
|
||
const name=(tc&&tc.name)||'tool';
|
||
if(name==='subagent_progress') return 'Subagent';
|
||
if(name==='delegate_task') return 'Delegate task';
|
||
return name;
|
||
}
|
||
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='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<=800){displaySnippet=s;}
|
||
else{
|
||
const cutoff=s.slice(0,800);
|
||
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 moreLabel=tc.is_diff?'Show diff':'Show more';
|
||
const lessLabel=tc.is_diff?'Hide diff':'Show less';
|
||
const runIndicator=tc.done===false?'<span class="tool-card-running-dot"></span>':'';
|
||
const isSubagent=tc.name==='subagent_progress';
|
||
const isDelegation=tc.name==='delegate_task';
|
||
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'');
|
||
// Clean up legacy subagent prefixes since the Lucide icon already shows it
|
||
let displayName=_toolDisplayName(tc);
|
||
let previewText=tc.preview||displaySnippet||'';
|
||
if(isSubagent) previewText=previewText.replace(/^(?:\u{1F500}|↳)\s*/u,'');
|
||
row.innerHTML=`
|
||
<div class="${cardClass}">
|
||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||
${runIndicator}
|
||
<span class="tool-card-icon">${icon}</span>
|
||
<span class="tool-card-name">${esc(displayName)}</span>
|
||
<span class="tool-card-preview">${esc(previewText)}</span>
|
||
${hasDetail?`<span class="tool-card-toggle">${li('chevron-right',12)}</span>`:''}
|
||
</div>
|
||
${hasDetail?`<div class="tool-card-detail">
|
||
${tc.args&&Object.keys(tc.args).length?`<div class="tool-card-args">${
|
||
Object.entries(tc.args).map(([k,v])=>`<div><span class="tool-arg-key">${esc(k)}</span> <span class="tool-arg-val">${esc(String(v))}</span></div>`).join('')
|
||
}</div>`:''}
|
||
${displaySnippet?`<div class="tool-card-result">
|
||
<pre>${esc(displaySnippet)}</pre>
|
||
${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'"')}" data-short="${esc(displaySnippet||'').replace(/"/g,'"')}" data-more-label="${esc(moreLabel)}" data-less-label="${esc(lessLabel)}" onclick="event.stopPropagation();const p=this.previousElementSibling;const full=this.dataset.full;const short=this.dataset.short;p.textContent=p.textContent===short?full:short;this.textContent=p.textContent===short?this.dataset.moreLabel:this.dataset.lessLabel">${esc(moreLabel)}</button>`:''}
|
||
</div>`:''}
|
||
</div>`:''}
|
||
</div>`;
|
||
return row;
|
||
}
|
||
|
||
function _syncToolCallGroupSummary(group){
|
||
if(!group) return;
|
||
const cards=Array.from(group.querySelectorAll('.tool-card-row .tool-card'));
|
||
const toolCount=cards.length;
|
||
const label=group.querySelector('.tool-call-group-label');
|
||
const durationEl=group.querySelector('.tool-call-group-duration');
|
||
if(label){
|
||
if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
|
||
else label.textContent='Activity';
|
||
label.setAttribute('data-sweep-label', label.textContent);
|
||
}
|
||
if(durationEl){
|
||
if(group.getAttribute('data-live-tool-call-group')==='1'){
|
||
const activeText=_activityElapsedLabel(group);
|
||
const progressText=_activityLiveProgressLabel(group);
|
||
if(activeText) group.setAttribute('data-active-turn-elapsed',activeText);
|
||
else group.removeAttribute('data-active-turn-elapsed');
|
||
durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · ');
|
||
durationEl.style.display=durationEl.textContent?'':'none';
|
||
}else{
|
||
const durationText=_formatTurnDuration(group.dataset.turnDuration);
|
||
durationEl.textContent=durationText?`Done in ${durationText}`:'';
|
||
durationEl.style.display=durationText?'':'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
function _activityProgressLabelForToolName(name){
|
||
const key=String(name||'').toLowerCase().replace(/[^a-z0-9]+/g,'_');
|
||
if(!key) return 'Working';
|
||
if(key.includes('search')||key.includes('grep')) return 'Searching workspace';
|
||
if(key.includes('read')||key.includes('view')||key.includes('open')) return 'Reading files';
|
||
if(key.includes('write')||key.includes('patch')||key.includes('edit')) return 'Updating files';
|
||
if(key.includes('terminal')||key.includes('shell')||key.includes('command')||key.includes('process')) return 'Running command';
|
||
if(key.includes('web')||key.includes('fetch')||key.includes('curl')) return 'Checking web data';
|
||
if(key.includes('todo')||key.includes('plan')) return 'Planning next steps';
|
||
return 'Working';
|
||
}
|
||
|
||
function _activityLiveProgressLabel(group){
|
||
if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return '';
|
||
const running=group.querySelector('.tool-card.tool-card-running .tool-card-name');
|
||
const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop();
|
||
return _activityProgressLabelForToolName(latest?latest.textContent:'');
|
||
}
|
||
|
||
// ── Live tool card helpers (called during SSE streaming) ──
|
||
// Live cards are inserted INLINE inside #msgInner (tagged with data-live-tid)
|
||
// so the streaming layout matches the settled layout produced by renderMessages
|
||
// (user → thinking → tool cards → response). The legacy #liveToolCards
|
||
// sibling container is no longer used for placement — keeping the cards in the
|
||
// message column eliminates the visible "jump" users saw when renderMessages
|
||
// fired on the done event.
|
||
function appendLiveToolCard(tc){
|
||
// Guard: ignore if session was switched. Prevents stale tool events from
|
||
// a previous session's SSE stream from manipulating the new session's DOM.
|
||
if(!S.session||!S.activeStreamId) return;
|
||
let turn=$('liveAssistantTurn');
|
||
if(!turn){
|
||
turn=_createAssistantTurn();
|
||
turn.id='liveAssistantTurn';
|
||
if(S.session) turn.dataset.sessionId=S.session.session_id; // see #1366
|
||
$('msgInner').appendChild(turn);
|
||
}
|
||
const inner=_assistantTurnBlocks(turn);
|
||
if(!inner) return;
|
||
const tid=tc.tid||'';
|
||
if(!isSimplifiedToolCalling()){
|
||
// Update existing card in place (tool_complete after tool_start)
|
||
if(tid){
|
||
const existing=inner.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||
if(existing){
|
||
const replacement=buildToolCard(tc);
|
||
replacement.dataset.liveTid=tid;
|
||
existing.replaceWith(replacement);
|
||
// Keep #toolRunningRow alive — dots stay until text starts streaming
|
||
// or the next tool fires (which replaces them). Removing here caused
|
||
// a gap between tool completion and the first text token arriving.
|
||
return;
|
||
}
|
||
}
|
||
const row=buildToolCard(tc);
|
||
if(tid) row.dataset.liveTid=tid;
|
||
// Insert after whichever comes last: the current live assistant segment or
|
||
// the last tool card. This handles both cases:
|
||
// text → tool1 → tool2 (no text between tools: anchor is card1)
|
||
// text1 → tool1 → text2 → tool2 (text between tools: anchor is text2)
|
||
const children=Array.from(inner.children);
|
||
// Include .thinking-card-row so tool cards land AFTER a finalized thinking
|
||
// card, not between the text segment and thinking.
|
||
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-card-row,.thinking-card-row')).pop();
|
||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||
else inner.appendChild(row);
|
||
// Add a 3-dot waiting indicator below the tool card so there's visual
|
||
// feedback while the tool is running. Removed when text starts streaming
|
||
// (ensureAssistantRow) or when tool_complete fires.
|
||
const oldWait=$('toolRunningRow');if(oldWait)oldWait.remove();
|
||
const waitRow=document.createElement('div');
|
||
waitRow.id='toolRunningRow';
|
||
waitRow.className='assistant-segment';
|
||
waitRow.innerHTML='<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>';
|
||
row.insertAdjacentElement('afterend', waitRow);
|
||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||
return;
|
||
}
|
||
const children=Array.from(inner.children);
|
||
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop();
|
||
const group=ensureActivityGroup(inner,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
|
||
const body=group.querySelector('.tool-call-group-body');
|
||
// Update existing card in place (tool_complete after tool_start)
|
||
if(tid){
|
||
const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
|
||
if(existing){
|
||
const replacement=buildToolCard(tc);
|
||
replacement.dataset.liveTid=tid;
|
||
existing.replaceWith(replacement);
|
||
_syncToolCallGroupSummary(group);
|
||
return;
|
||
}
|
||
}
|
||
const row=buildToolCard(tc);
|
||
if(tid) row.dataset.liveTid=tid;
|
||
body.appendChild(row);
|
||
_syncToolCallGroupSummary(group);
|
||
if(typeof scrollIfPinned==='function') scrollIfPinned();
|
||
}
|
||
|
||
function clearLiveToolCards(){
|
||
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
|
||
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
|
||
if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove());
|
||
// Reset the per-turn user expand intent so the next turn starts at the
|
||
// default collapsed state (#1298).
|
||
if(typeof _clearLiveActivityUserIntent==='function') _clearLiveActivityUserIntent();
|
||
// Legacy #liveToolCards container cleanup — kept for safety in case any
|
||
// leftover cards were inserted there before this refactor took effect.
|
||
const container=$('liveToolCards');
|
||
if(container){container.innerHTML='';container.style.display='none';}
|
||
}
|
||
|
||
// ── Edit + Regenerate ──
|
||
|
||
function editMessage(btn) {
|
||
if(S.busy) return;
|
||
const row = btn.closest('[data-msg-idx]');
|
||
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 = `<button class="msg-edit-send">Send edit</button><button class="msg-edit-cancel">Cancel</button>`;
|
||
ta.after(bar);
|
||
|
||
bar.querySelector('.msg-edit-send').onclick = async () => {
|
||
const newText = ta.value.trim();
|
||
if(!newText) return;
|
||
await submitEdit(msgIdx, newText);
|
||
};
|
||
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
|
||
|
||
ta.addEventListener('keydown', e => {
|
||
if(e.key==='Enter' && !e.shiftKey) { if(window._isImeEnter&&window._isImeEnter(e)) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
|
||
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
|
||
});
|
||
}
|
||
|
||
function cancelEdit(row, originalText, originalBody) {
|
||
delete row.dataset.editing;
|
||
const ta = row.querySelector('.msg-edit-area');
|
||
const bar = row.querySelector('.msg-edit-bar');
|
||
if(ta) ta.replaceWith(originalBody);
|
||
if(bar) bar.remove();
|
||
}
|
||
|
||
function autoResizeTextarea(ta) {
|
||
ta.style.height = 'auto';
|
||
ta.style.height = Math.min(ta.scrollHeight, 300) + 'px';
|
||
}
|
||
|
||
async function submitEdit(msgIdx, newText) {
|
||
if(!S.session || S.busy) return;
|
||
// Truncate session at msgIdx (keep messages before the edited one)
|
||
// then re-send the edited text
|
||
try {
|
||
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
|
||
session_id: S.session.session_id,
|
||
keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward
|
||
})});
|
||
S.messages = S.messages.slice(0, msgIdx);
|
||
renderMessages();
|
||
// Now send the edited message as a new chat
|
||
$('msg').value = newText;
|
||
await send();
|
||
} catch(e) { setStatus(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('[data-msg-idx]');
|
||
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 postProcessRenderedMessages(container) {
|
||
highlightCode(container);
|
||
addCopyButtons(container);
|
||
loadDiffInline(container);
|
||
loadCsvInline(container);
|
||
loadExcalidrawInline(container);
|
||
loadPdfInline(container);
|
||
loadHtmlInline(container);
|
||
renderMermaidBlocks(container);
|
||
renderKatexBlocks(container);
|
||
initTreeViews(container);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Lazy load js-yaml for YAML tree view support
|
||
let _jsyamlLoading=false;
|
||
function _loadJsyamlThen(cb){
|
||
if(typeof jsyaml!=='undefined'){ cb(); return; }
|
||
if(_jsyamlLoading){ setTimeout(()=>_loadJsyamlThen(cb),100); return; }
|
||
_jsyamlLoading=true;
|
||
const s=document.createElement('script');
|
||
s.src='https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js';
|
||
s.integrity='sha384-8pLvVQkv7pCQqFk7AChLpdEe7gXz9h8GAb7cS0zVeJuKhxR5PU5aEET5pRpHZvxUorzdM';
|
||
s.crossOrigin='anonymous';
|
||
s.onload=()=>{ _jsyamlLoading=false; cb(); };
|
||
s.onerror=()=>{ _jsyamlLoading=false; }; // CDN blocked, fall back to raw
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function initTreeViews(container){
|
||
const root=container||document;
|
||
root.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
|
||
const rawText=wrap.dataset.raw;
|
||
const lang=wrap.dataset.lang;
|
||
let parsed=null;
|
||
let parseFailed=false;
|
||
// Try JSON parse
|
||
try{ parsed=JSON.parse(rawText); }catch(e){ parseFailed=(lang==='json'); }
|
||
// YAML: lazy-load js-yaml if needed
|
||
if(!parsed && lang==='yaml'){
|
||
if(typeof jsyaml!=='undefined'){
|
||
try{ parsed=jsyaml.load(rawText); }catch(e){ parseFailed=true; }
|
||
}else{
|
||
// Defer: remove init marker so we retry after load.
|
||
// Note: if CDN load fails, s.onerror does NOT call back —
|
||
// the wrap stays un-initialised (raw view only), which is safe.
|
||
wrap.removeAttribute('data-tree-init');
|
||
_loadJsyamlThen(initTreeViews);
|
||
return;
|
||
}
|
||
}
|
||
// Mark as initialised only after we've committed to a render decision
|
||
wrap.setAttribute('data-tree-init','1');
|
||
if(!parsed || typeof parsed!=='object'){
|
||
if(parseFailed){
|
||
const hint=wrap.querySelector('.tree-raw-view');
|
||
if(hint&&!hint.querySelector('.tree-parse-note')){
|
||
const note=document.createElement('div');
|
||
note.className='tree-parse-note';
|
||
note.textContent=t('parse_failed_note')||'parse failed';
|
||
hint.parentNode.insertBefore(note,hint.nextSibling);
|
||
}
|
||
}
|
||
return; // leave as raw view
|
||
}
|
||
const lineCount=rawText.split('\n').length;
|
||
// Default to raw for short blocks (<10 lines), tree for longer
|
||
const showTree=lineCount>=10;
|
||
// Build tree DOM
|
||
const treeDiv=document.createElement('div');
|
||
treeDiv.className='tree-view'+(showTree?'':' tree-hidden');
|
||
treeDiv.appendChild(_buildTreeDOM(parsed, 0));
|
||
// Toggle button in header
|
||
const header=wrap.querySelector('.pre-header');
|
||
if(header){
|
||
const toggle=document.createElement('button');
|
||
toggle.className='tree-toggle-btn';
|
||
toggle.textContent=showTree?t('raw_view'):t('tree_view');
|
||
toggle.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
const isTreeHidden=treeDiv.classList.contains('tree-hidden');
|
||
treeDiv.classList.toggle('tree-hidden',!isTreeHidden);
|
||
const rawPre=wrap.querySelector('.tree-raw-view');
|
||
if(rawPre) rawPre.style.display=isTreeHidden?'none':'';
|
||
toggle.textContent=isTreeHidden?t('raw_view'):t('tree_view');
|
||
};
|
||
header.style.display='flex';
|
||
header.style.justifyContent='space-between';
|
||
header.style.alignItems='center';
|
||
header.appendChild(toggle);
|
||
}
|
||
if(!showTree){
|
||
const rawPre=wrap.querySelector('.tree-raw-view');
|
||
if(rawPre) rawPre.style.display='';
|
||
} else {
|
||
const rawPre=wrap.querySelector('.tree-raw-view');
|
||
if(rawPre) rawPre.style.display='none';
|
||
}
|
||
wrap.appendChild(treeDiv);
|
||
});
|
||
}
|
||
|
||
function _buildTreeDOM(val, depth){
|
||
const el=document.createElement('div');
|
||
el.className='tree-node';
|
||
if(val===null){ el.innerHTML=`<span class="tree-val tree-null">null</span>`; return el; }
|
||
if(typeof val==='boolean'){ el.innerHTML=`<span class="tree-val tree-bool">${val}</span>`; return el; }
|
||
if(typeof val==='number'){ el.innerHTML=`<span class="tree-val tree-num">${val}</span>`; return el; }
|
||
if(typeof val==='string'){ el.innerHTML=`<span class="tree-val tree-str">"${esc(val)}"</span>`; return el; }
|
||
if(Array.isArray(val)){
|
||
el.classList.add('tree-array');
|
||
const collapsed=depth>=2;
|
||
const header=document.createElement('span');
|
||
header.className='tree-collapsible';
|
||
header.innerHTML=(collapsed?'▸ ': '▾ ')+`<span class="tree-bracket">[</span><span class="tree-count">${val.length}</span><span class="tree-bracket">]</span>`;
|
||
const body=document.createElement('div');
|
||
body.className='tree-children'+(collapsed?' tree-collapsed':'');
|
||
val.forEach((item,i)=>{
|
||
const child=document.createElement('div');
|
||
child.className='tree-item';
|
||
child.appendChild(_buildTreeDOM(item, depth+1));
|
||
if(i<val.length-1) child.innerHTML+='<span class="tree-comma">,</span>';
|
||
body.appendChild(child);
|
||
});
|
||
el.appendChild(header);
|
||
el.appendChild(body);
|
||
header.onclick=(()=>{const c=body.classList.contains('tree-collapsed'); body.classList.toggle('tree-collapsed'); header.innerHTML=(c?'▾ ':'▸ ')+`<span class="tree-bracket">[</span><span class="tree-count">${val.length}</span><span class="tree-bracket">]</span>`;});
|
||
return el;
|
||
}
|
||
if(typeof val==='object'){
|
||
el.classList.add('tree-object');
|
||
const keys=Object.keys(val);
|
||
const collapsed=depth>=2;
|
||
const header=document.createElement('span');
|
||
header.className='tree-collapsible';
|
||
header.innerHTML=(collapsed?'▸ ': '▾ ')+`<span class="tree-bracket">{</span><span class="tree-count">${keys.length}</span><span class="tree-bracket">}</span>`;
|
||
const body=document.createElement('div');
|
||
body.className='tree-children'+(collapsed?' tree-collapsed':'');
|
||
keys.forEach((key,i)=>{
|
||
const child=document.createElement('div');
|
||
child.className='tree-item';
|
||
child.innerHTML=`<span class="tree-key">"${esc(key)}"</span><span class="tree-colon">: </span>`;
|
||
child.appendChild(_buildTreeDOM(val[key], depth+1));
|
||
if(i<keys.length-1) child.innerHTML+='<span class="tree-comma">,</span>';
|
||
body.appendChild(child);
|
||
});
|
||
el.appendChild(header);
|
||
el.appendChild(body);
|
||
header.onclick=(()=>{const c=body.classList.contains('tree-collapsed'); body.classList.toggle('tree-collapsed'); header.innerHTML=(c?'▾ ':'▸ ')+`<span class="tree-bracket">{</span><span class="tree-count">${keys.length}</span><span class="tree-bracket">}</span>`;});
|
||
return el;
|
||
}
|
||
el.innerHTML=`<span class="tree-val">${esc(String(val))}</span>`;
|
||
return el;
|
||
}
|
||
|
||
function addCopyButtons(container){
|
||
const el=container||$('msgInner');
|
||
if(!el) return;
|
||
el.querySelectorAll('pre > code').forEach(codeEl=>{
|
||
const pre=codeEl.parentElement;
|
||
const header=pre.previousElementSibling;
|
||
if(pre.querySelector('.code-copy-btn')||(header&&header.classList.contains('pre-header')&&header.querySelector('.code-copy-btn'))) return;
|
||
const btn=document.createElement('button');
|
||
btn.className='code-copy-btn';
|
||
btn.textContent=t('copy');
|
||
btn.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
_copyText(codeEl.textContent).then(()=>{
|
||
btn.textContent=t('copied');
|
||
setTimeout(()=>{btn.textContent=t('copy');},1500);
|
||
}).catch(()=>{btn.textContent=t('copy_failed');setTimeout(()=>{btn.textContent=t('copy');},1500);});
|
||
};
|
||
if(header&&header.classList.contains('pre-header')){
|
||
header.style.display='flex';
|
||
header.style.justifyContent='space-between';
|
||
header.style.alignItems='center';
|
||
header.appendChild(btn);
|
||
}else{
|
||
pre.style.position='relative';
|
||
btn.style.cssText='position:absolute;top:6px;right:6px;';
|
||
pre.appendChild(btn);
|
||
}
|
||
});
|
||
}
|
||
|
||
let _mermaidLoading=false;
|
||
let _mermaidReady=false;
|
||
|
||
function loadDiffInline(container){
|
||
const DIFF_MAX_SIZE=512*1024; // 512 KB cap for inline diff rendering
|
||
const root=container||document;
|
||
root.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
|
||
el.setAttribute('data-loaded','1');
|
||
const path=el.dataset.path;
|
||
fetch('api/media?path='+encodeURIComponent(path))
|
||
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
|
||
.then(text=>{
|
||
if(text.length>DIFF_MAX_SIZE){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('diff_too_large')}</span></div>`;
|
||
return;
|
||
}
|
||
const lines=text.split('\n').map(line=>{
|
||
const e=esc(line);
|
||
if(e.startsWith('@@')) return `<span class="diff-line diff-hunk">${e}</span>`;
|
||
if(e.startsWith('+')) return `<span class="diff-line diff-plus">${e}</span>`;
|
||
if(e.startsWith('-')) return `<span class="diff-line diff-minus">${e}</span>`;
|
||
return `<span class="diff-line">${e}</span>`;
|
||
}).join('\n');
|
||
el.outerHTML=`<div class="diff-inline"><div class="pre-header">${esc(path.split('/').pop())}</div><pre class="diff-block"><code>${lines}</code></pre></div>`;
|
||
})
|
||
.catch(()=>{
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('diff_error')}</span></div>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
function loadCsvInline(container){
|
||
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
|
||
const root=container||document;
|
||
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
|
||
el.setAttribute('data-loaded','1');
|
||
const path=el.dataset.path;
|
||
fetch('api/media?path='+encodeURIComponent(path))
|
||
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
|
||
.then(text=>{
|
||
if(text.length>CSV_MAX_SIZE){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_too_large')}</span></div>`;
|
||
return;
|
||
}
|
||
const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim());
|
||
if(rows.length<2){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_no_data')}</span></div>`;
|
||
return;
|
||
}
|
||
// Auto-detect separator (comma, semicolon, tab)
|
||
// Heuristic: uses the first separator found in the header row. Edge case:
|
||
// quoted fields containing commas without non-quoted commas in the header
|
||
// could cause misdetection — acceptable trade-off for a preview renderer.
|
||
const firstLine=rows[0];
|
||
const separators=[',',';','\t'];
|
||
let sep=separators.find(s=>firstLine.includes(s))||',';
|
||
const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,''));
|
||
const bodyRows=rows.slice(1).map(r=>'<tr>'+r.split(sep).map(c=>`<td>${esc(c.trim().replace(/^["']|["']$/g,''))}</td>`).join('')+'</tr>').join('');
|
||
const headerRow=headers.map(h=>`<th>${esc(h)}</th>`).join('');
|
||
el.outerHTML=`<div class="csv-table-wrap"><div class="pre-header">${esc(path.split('/').pop())} <span style="opacity:.5;font-size:11px">${t('csv_header_note')}</span></div><table class="csv-table"><thead><tr>${headerRow}</tr></thead><tbody>${bodyRows}</tbody></table></div>`;
|
||
})
|
||
.catch(()=>{
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_error')}</span></div>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
function loadExcalidrawInline(container){
|
||
const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap
|
||
const root=container||document;
|
||
root.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
|
||
el.setAttribute('data-loaded','1');
|
||
const path=el.dataset.path;
|
||
fetch('api/media?path='+encodeURIComponent(path))
|
||
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
|
||
.then(text=>{
|
||
if(text.length>EXCALIDRAW_MAX_SIZE){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_too_large')}</span></div>`;
|
||
return;
|
||
}
|
||
// Validate it looks like Excalidraw JSON
|
||
let data;
|
||
try{data=JSON.parse(text);}catch(e){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_invalid')}</span></div>`;
|
||
return;
|
||
}
|
||
if(!data.type||data.type!=='excalidraw'){
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_invalid')}</span></div>`;
|
||
return;
|
||
}
|
||
const fname=esc(path.split('/').pop());
|
||
const downloadUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||
el.outerHTML=`<div class="excalidraw-embed-wrap" title="${t('excalidraw_simplified')}">
|
||
<div class="msg-artifact-header">
|
||
<span class="msg-media-label">${t('excalidraw_label')}</span>
|
||
<a class="excalidraw-open-link" href="${downloadUrl}" download="${fname}">${t('excalidraw_download')} ${fname}</a>
|
||
</div>
|
||
<div class="excalidraw-canvas" data-excalidraw='${esc(text)}'></div>
|
||
</div>`;
|
||
// Lazy-init Excalidraw render after DOM insertion
|
||
requestAnimationFrame(()=>_renderExcalidrawCanvases());
|
||
})
|
||
.catch(()=>{
|
||
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('excalidraw_error')}</span></div>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
let _excalidrawScriptLoaded=false;
|
||
function _renderExcalidrawCanvases(){
|
||
document.querySelectorAll('.excalidraw-canvas:not([data-rendered])').forEach(el=>{
|
||
el.setAttribute('data-rendered','1');
|
||
const dataStr=el.getAttribute('data-excalidraw');
|
||
if(!dataStr) return;
|
||
// Render a simple SVG preview using the Excalidraw elements
|
||
try{
|
||
const data=JSON.parse(dataStr);
|
||
const elements=data.elements||[];
|
||
if(!elements.length){el.innerHTML=`<div class="excalidraw-empty">${t('excalidraw_empty')}</div>`;return;}
|
||
// Calculate bounds
|
||
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
||
elements.forEach(el=>{
|
||
const b=[el.x||0,el.y||0,(el.x||0)+(el.width||0),(el.y||0)+(el.height||0)];
|
||
minX=Math.min(minX,b[0]);minY=Math.min(minY,b[1]);
|
||
maxX=Math.max(maxX,b[2]);maxY=Math.max(maxY,b[3]);
|
||
});
|
||
const pad=20;minX-=pad;minY-=pad;maxX+=pad;maxY+=pad;
|
||
const w=Math.max(maxX-minX,200);const h=Math.max(maxY-minY,150);
|
||
// SVG attributes are rendered via innerHTML below, so attacker-controlled
|
||
// values from JSON (e.g. strokeColor='red"/><script>...') would break out
|
||
// of the attribute. Escape strings; coerce numerics.
|
||
const _sa=v=>String(v==null?'':v).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||
const _num=(v,fb)=>{const n=Number(v);return Number.isFinite(n)?n:fb;};
|
||
const svgParts=[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${_num(minX,0)} ${_num(minY,0)} ${_num(w,200)} ${_num(h,150)}" class="excalidraw-svg">`];
|
||
elements.forEach(el=>{
|
||
const stroke=_sa(el.strokeColor||'#1e1e1e');
|
||
const fill=_sa(el.backgroundColor||'transparent');
|
||
const sw=_num(el.strokeWidth,2);
|
||
const x=_num(el.x,0),y=_num(el.y,0),w=_num(el.width,0),h=_num(el.height,0);
|
||
if(el.type==='rectangle'){
|
||
svgParts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}" rx="${el.roundness?.type===3?8:0}"/>`);
|
||
}else if(el.type==='diamond'){
|
||
const cx=x+w/2,cy=y+h/2;
|
||
svgParts.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}"/>`);
|
||
}else if(el.type==='ellipse'){
|
||
svgParts.push(`<ellipse cx="${x+w/2}" cy="${y+h/2}" rx="${w/2}" ry="${h/2}" stroke="${stroke}" stroke-width="${sw}" fill="${fill}"/>`);
|
||
}else if(el.type==='line'){
|
||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||
if(!pts.length) return;
|
||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||
}else if(el.type==='arrow'){
|
||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||
if(!pts.length) return;
|
||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrowhead)"/>`);
|
||
}else if(el.type==='text'){
|
||
const fontSize=_num(el.fontSize,20);
|
||
const txt=String(el.text==null?'':el.text);
|
||
const lines=txt.split('\n');
|
||
lines.forEach((line,i)=>{
|
||
svgParts.push(`<text x="${x}" y="${y+i*fontSize*1.2+fontSize}" fill="${stroke}" font-size="${fontSize}" font-family="Virgil, Segoe UI Emoji, sans-serif">${esc(line)}</text>`);
|
||
});
|
||
}else if(el.type==='draw'){
|
||
const pts=(el.points||[]).filter(p=>Array.isArray(p)&&p.length>=2);
|
||
if(pts.length>1){
|
||
let d=`M ${_num(x+_num(pts[0][0],0),0)} ${_num(y+_num(pts[0][1],0),0)}`;
|
||
for(let i=1;i<pts.length;i++) d+=` L ${_num(x+_num(pts[i][0],0),0)} ${_num(y+_num(pts[i][1],0),0)}`;
|
||
svgParts.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||
}
|
||
}
|
||
// Unknown element types (e.g. image, frame, group, freedraw) are
|
||
// silently skipped to avoid breaking the render. This is a simplified
|
||
// SVG preview, not a pixel-identical Excalidraw canvas reproduction.
|
||
});
|
||
// Arrow marker definition
|
||
svgParts.unshift(`<defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#1e1e1e"/></marker></defs>`);
|
||
svgParts.push('</svg>');
|
||
el.innerHTML=svgParts.join('');
|
||
}catch(e){
|
||
el.innerHTML=`<div class="excalidraw-empty">${t('excalidraw_render_error')}</div>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── PDF inline preview (first page) ────────────────────────────────────────
|
||
// NOTE: PDF.js is loaded from CDN (jsdelivr). Offline/air-gapped deployments
|
||
// will not get inline previews; the 15 s fallback timeout degrades to a
|
||
// download link in that case. The 4 MB size cap is checked client-side after
|
||
// the full buffer is received — ideally the server would enforce it before
|
||
// streaming (out of scope for this client-side PR).
|
||
let _pdfjsReady=false, _pdfjsLoading=false;
|
||
function loadPdfInline(container){
|
||
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
|
||
const root=container||document;
|
||
root.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
|
||
el.setAttribute('data-loaded','1');
|
||
const path=el.dataset.path;
|
||
const fname=path.split('/').pop()||path;
|
||
const loadPdf=(pdfjsLib)=>{
|
||
fetch('api/media?path='+encodeURIComponent(path))
|
||
.then(r=>{if(!r.ok) throw new Error(r.status); return r.arrayBuffer();})
|
||
.then(buf=>{
|
||
if(buf.byteLength>PDF_MAX_SIZE){
|
||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="api/media?path=${encodeURIComponent(path)}&download=1" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_too_large')}</span></div>`;
|
||
return;
|
||
}
|
||
return pdfjsLib.getDocument({data:buf}).promise;
|
||
})
|
||
.then(pdf=>{
|
||
if(!pdf) return;
|
||
pdf.getPage(1).then(page=>{
|
||
const canvas=document.createElement('canvas');
|
||
const scale=1.5;
|
||
const viewport=page.getViewport({scale});
|
||
canvas.width=viewport.width;
|
||
canvas.height=viewport.height;
|
||
canvas.className='pdf-preview-canvas';
|
||
page.render({canvasContext:canvas.getContext('2d'),viewport}).promise.then(()=>{
|
||
// Canvas bitmap is runtime state, not part of HTML serialization.
|
||
// Attach the canvas as a DOM node — interpolating its serialized
|
||
// form into a template string parses back as an empty canvas.
|
||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||
const wrap=document.createElement('div');
|
||
wrap.className='pdf-preview-wrap';
|
||
wrap.innerHTML=`<div class="pdf-preview-header"><span>📄 ${esc(fname)}</span><a href="${dlUrl}" download="${esc(fname)}" class="pdf-download-link">${t('pdf_download')} ↓</a></div><div class="pdf-preview-body"></div>`;
|
||
wrap.querySelector('.pdf-preview-body').appendChild(canvas);
|
||
el.replaceWith(wrap);
|
||
});
|
||
});
|
||
})
|
||
.catch(()=>{
|
||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
|
||
});
|
||
};
|
||
if(_pdfjsReady){
|
||
loadPdf(window._pdfjsLib);
|
||
} else if(!_pdfjsLoading){
|
||
_pdfjsLoading=true;
|
||
const s=document.createElement('script');
|
||
s.src='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.min.mjs';
|
||
s.type='module';
|
||
s.textContent=`
|
||
import * as pdfjsLib from '${s.src}';
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdn.jsdelivr.net/npm/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs';
|
||
window._pdfjsLib=pdfjsLib;
|
||
window._pdfjsReady=true;
|
||
window.dispatchEvent(new Event('pdfjs-ready'));
|
||
`;
|
||
document.head.appendChild(s);
|
||
window.addEventListener('pdfjs-ready',()=>{ _pdfjsReady=true; loadPdf(window._pdfjsLib); },{once:true});
|
||
setTimeout(()=>{
|
||
if(!_pdfjsReady){
|
||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||
if(el.parentNode){
|
||
el.outerHTML=`<div class="pdf-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('pdf_error')}</span></div>`;
|
||
}
|
||
}
|
||
},15000);
|
||
} else {
|
||
window.addEventListener('pdfjs-ready',()=>{ loadPdf(window._pdfjsLib); },{once:true});
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
|
||
function loadHtmlInline(container){
|
||
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
|
||
const root=container||document;
|
||
root.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
|
||
el.setAttribute('data-loaded','1');
|
||
const path=el.dataset.path;
|
||
const fname=path.split('/').pop()||path;
|
||
fetch('api/media?path='+encodeURIComponent(path))
|
||
.then(r=>{if(!r.ok) throw new Error(r.status); return r.text();})
|
||
.then(html=>{
|
||
if(html.length>HTML_MAX_SIZE){
|
||
const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
|
||
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${openUrl}" target="_blank" rel="noopener">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_too_large')}</span></div>`;
|
||
return;
|
||
}
|
||
const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
|
||
const safeHtml=html.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||
el.outerHTML=`<div class="html-preview-wrap"><div class="html-preview-header"><span>${t('html_sandbox_label')}</span><a href="${openUrl}" target="_blank" rel="noopener" class="html-open-link">${t('html_open_full')} ↗</a></div><iframe srcdoc="${safeHtml}" sandbox="allow-scripts" class="html-preview-iframe" loading="lazy"></iframe></div>`;
|
||
})
|
||
.catch(()=>{
|
||
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
|
||
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_error')}</span></div>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderMermaidBlocks(container){
|
||
const root=container||document;
|
||
const blocks=root.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:document.documentElement.classList.contains('dark')?'dark':'default',themeVariables:{
|
||
fontFamily:'inherit',fontSize:'14px',
|
||
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);
|
||
const tmp=document.getElementById('d'+id);
|
||
if(tmp) tmp.remove();
|
||
block.innerHTML=svg;
|
||
block.classList.add('mermaid-rendered');
|
||
}catch(e){
|
||
const tmp=document.getElementById('d'+id);
|
||
if(tmp) tmp.remove();
|
||
// Fall back to showing as a code block. Remove the mermaid marker so a
|
||
// later render pass cannot retry this already-failed block.
|
||
block.classList.remove('mermaid-block');
|
||
block.classList.add('prewrap');
|
||
block.innerHTML=`<div class="pre-header">mermaid</div><pre><code>${esc(code)}</code></pre>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
let _katexLoading=false;
|
||
let _katexReady=false;
|
||
|
||
function renderKatexBlocks(container){
|
||
const root=container||document;
|
||
const blocks=root.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=`<code>${esc(src)}</code>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _thinkingMarkup(text=''){
|
||
const clean=_sanitizeThinkingDisplayText(text);
|
||
const openClass=isSimplifiedToolCalling()?'':' open';
|
||
return (clean&&String(clean).trim())
|
||
? `<div class="thinking-card${openClass}"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(String(clean).trim())}</pre></div></div>`
|
||
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||
}
|
||
function _renderThinkingInto(row,text=''){
|
||
if(!row) return;
|
||
const clean=_sanitizeThinkingDisplayText(text);
|
||
if(!clean){
|
||
row.innerHTML=_thinkingMarkup(text);
|
||
return;
|
||
}
|
||
const pre=row.querySelector('.thinking-card-body pre');
|
||
if(pre){
|
||
pre.textContent=clean;
|
||
return;
|
||
}
|
||
row.innerHTML=_thinkingMarkup(text);
|
||
}
|
||
function finalizeThinkingCard(){
|
||
// Guard: only finalize thinking card if we're looking at the session that started it.
|
||
// Without this check, switching tabs while a stream is running causes finalizeThinkingCard
|
||
// to remove/modify the thinking card DOM of the wrong session — the card belongs to the
|
||
// stream that started it, not the session currently displayed.
|
||
const _guardTurn = $('liveAssistantTurn');
|
||
if(_guardTurn && S.session && _guardTurn.dataset.sessionId !== S.session.session_id) return;
|
||
if(!isSimplifiedToolCalling()){
|
||
const row=$('thinkingRow');
|
||
if(!row) return;
|
||
// If the row is still just a spinner (no thinking content rendered),
|
||
// remove it entirely — it's the initial waiting dots.
|
||
const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row');
|
||
if(!hasContent && row.getAttribute('data-thinking-active')==='1'){
|
||
row.remove();
|
||
return;
|
||
}
|
||
// If the user was watching (scroll pinned = at bottom), scroll the thinking
|
||
// card back to the top so the completed response is visible underneath without
|
||
// the thinking content blocking it. If they scrolled up to read history,
|
||
// leave their scroll position intact.
|
||
if(_scrollPinned){
|
||
const body=row&&row.querySelector('.thinking-card-body');
|
||
if(body) body.scrollTop=0;
|
||
}
|
||
row.removeAttribute('id');
|
||
row.removeAttribute('data-thinking-active');
|
||
return;
|
||
}
|
||
const turn=$('liveAssistantTurn');
|
||
const group=turn&&turn.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
|
||
if(group){
|
||
// Respect the user's explicit expand intent (#1298) — only force-collapse
|
||
// when the user has not manually expanded this turn's activity group, or
|
||
// has manually collapsed it. Otherwise the panel snaps shut whenever new
|
||
// activity arrives, even mid-read.
|
||
if(_liveActivityUserExpanded !== true){
|
||
group.classList.add('tool-call-group-collapsed');
|
||
const summary=group.querySelector('.tool-call-group-summary');
|
||
if(summary) summary.setAttribute('aria-expanded','false');
|
||
}
|
||
const active=turn.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||
if(active) active.removeAttribute('data-thinking-active');
|
||
_syncToolCallGroupSummary(group);
|
||
}
|
||
}
|
||
function appendThinking(text='', options){
|
||
// Guard: ignore if session was switched during an async SSE stream.
|
||
// The old stream's reasoning events can still fire after switch;
|
||
// without this check they would pollute the new session's DOM.
|
||
const allowPendingPlaceholder=!!(options&&options.pending===true);
|
||
if(!S.session||(!S.activeStreamId&&!allowPendingPlaceholder)) return;
|
||
$('emptyState').style.display='none';
|
||
let turn=$('liveAssistantTurn');
|
||
if(!turn){
|
||
turn=_createAssistantTurn();
|
||
turn.id='liveAssistantTurn';
|
||
if(S.session) turn.dataset.sessionId=S.session.session_id; // see #1366
|
||
$('msgInner').appendChild(turn);
|
||
}
|
||
const blocks=_assistantTurnBlocks(turn);
|
||
if(!blocks) return;
|
||
if(!isSimplifiedToolCalling()){
|
||
let row=$('thinkingRow');
|
||
if(!row){
|
||
row=document.createElement('div');
|
||
row.className='assistant-segment';
|
||
row.id='thinkingRow';
|
||
row.setAttribute('data-thinking-active','1');
|
||
// Insert after whichever comes last: a live assistant segment or a tool card.
|
||
// This mirrors appendLiveToolCard's anchor logic so thinking always appears
|
||
// in the right position in the interleaved sequence.
|
||
// Also skip #toolRunningRow (dots) — thinking should go before dots, not after.
|
||
const allChildren=Array.from(blocks.children);
|
||
const anchor=allChildren.filter(el=>
|
||
el.id!=='toolRunningRow' &&
|
||
el.matches('[data-live-assistant="1"],.tool-card-row')
|
||
).pop();
|
||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||
else blocks.appendChild(row);
|
||
}
|
||
const clean=_sanitizeThinkingDisplayText(text);
|
||
const hasClean=!!String(clean||'').trim();
|
||
row.className=hasClean?'assistant-segment thinking-card-row':'assistant-segment';
|
||
_renderThinkingInto(row,text);
|
||
scrollIfPinned();
|
||
// Auto-scroll the thinking card body to bottom if the user is watching
|
||
// (scroll pinned). If the user scrolled up to read history, leave it alone.
|
||
if(_scrollPinned){
|
||
const body=row&&row.querySelector('.thinking-card-body');
|
||
if(body) body.scrollTop=body.scrollHeight;
|
||
}
|
||
return;
|
||
}
|
||
const thinkingText=String(text||'').trim()||'Thinking…';
|
||
let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
|
||
if(!row){
|
||
const thinkingCards=Array.from(blocks.querySelectorAll('.agent-activity-thinking'));
|
||
row=thinkingCards.filter(el=>el.closest('.assistant-turn-blocks')===blocks).pop()||null;
|
||
if(row) row.setAttribute('data-thinking-active','1');
|
||
}
|
||
if(!row){
|
||
row=_thinkingActivityNode(thinkingText, false);
|
||
row.setAttribute('data-thinking-active','1');
|
||
const allChildren=Array.from(blocks.children);
|
||
const anchor=allChildren.filter(el=>
|
||
el.id!=='toolRunningRow' &&
|
||
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
|
||
).pop();
|
||
if(anchor) anchor.insertAdjacentElement('afterend', row);
|
||
else blocks.appendChild(row);
|
||
}else{
|
||
_renderThinkingInto(row,thinkingText);
|
||
}
|
||
scrollIfPinned();
|
||
if(_scrollPinned){
|
||
const body=row&&row.querySelector('.thinking-card-body');
|
||
if(body) body.scrollTop=body.scrollHeight;
|
||
}
|
||
}
|
||
function updateThinking(text=''){appendThinking(text);}
|
||
function removeThinking(){
|
||
if(!isSimplifiedToolCalling()){
|
||
const el=$('thinkingRow');
|
||
if(el) el.remove();
|
||
const turn=$('liveAssistantTurn');
|
||
const blocks=_assistantTurnBlocks(turn);
|
||
if(turn&&blocks&&!blocks.children.length) turn.remove();
|
||
return;
|
||
}
|
||
const turn=$('liveAssistantTurn');
|
||
const blocks=_assistantTurnBlocks(turn);
|
||
if(blocks) blocks.querySelectorAll('.agent-activity-thinking').forEach(el=>el.remove());
|
||
if(blocks) blocks.querySelectorAll('.tool-call-group[data-agent-activity-group="1"]').forEach(group=>{
|
||
_syncToolCallGroupSummary(group);
|
||
if(!group.querySelector('.tool-card-row,.agent-activity-thinking')){
|
||
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
|
||
group.remove();
|
||
}
|
||
});
|
||
if(turn&&blocks&&!blocks.children.length) turn.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;i<parts.length;i++){
|
||
const sep=document.createElement('span');
|
||
sep.className='breadcrumb-sep';sep.textContent='/';
|
||
bar.appendChild(sep);
|
||
accumulated+=(accumulated?'/':'')+parts[i];
|
||
const seg=document.createElement('span');
|
||
seg.textContent=parts[i];
|
||
if(i<parts.length-1){
|
||
seg.className='breadcrumb-seg breadcrumb-link';
|
||
const target=accumulated;
|
||
seg.onclick=()=>loadDir(target);
|
||
} else {
|
||
seg.className='breadcrumb-seg breadcrumb-current';
|
||
}
|
||
bar.appendChild(seg);
|
||
}
|
||
}
|
||
|
||
const WORKSPACE_HIDDEN_FILE_NAMES=new Set([
|
||
'.DS_Store','._.DS_Store','.AppleDouble','.Spotlight-V100','.Trashes','.fseventsd',
|
||
'Thumbs.db','Desktop.ini','ehthumbs.db','$RECYCLE.BIN',
|
||
'.directory','.git','.svn','.hg','node_modules','__pycache__',
|
||
'.pytest_cache','.mypy_cache','.ruff_cache','.tox','.venv','venv'
|
||
]);
|
||
const WORKSPACE_HIDDEN_FILE_PREFIXES=['._','.Trash-'];
|
||
function _workspaceShouldHideEntry(item){
|
||
if(!item||S.showHiddenWorkspaceFiles)return false;
|
||
const name=String(item.name||'');
|
||
if(!name)return false;
|
||
if(WORKSPACE_HIDDEN_FILE_NAMES.has(name))return true;
|
||
return WORKSPACE_HIDDEN_FILE_PREFIXES.some(prefix=>name.startsWith(prefix));
|
||
}
|
||
function _visibleWorkspaceEntries(entries){
|
||
const list=Array.isArray(entries)?entries:[];
|
||
return S.showHiddenWorkspaceFiles?list:list.filter(item=>!_workspaceShouldHideEntry(item));
|
||
}
|
||
function _syncWorkspaceHiddenToggle(){
|
||
const el=$('workspaceShowHiddenFiles');
|
||
if(el)el.checked=!!S.showHiddenWorkspaceFiles;
|
||
// Reflect "hidden files are visible" state on the panel heading + kebab dot,
|
||
// so users can see they've flipped a non-default workspace pref without
|
||
// having to open the menu. The menu itself stays out of the way otherwise.
|
||
const ind=$('workspaceHiddenIndicator');
|
||
if(ind){
|
||
if(S.showHiddenWorkspaceFiles){ ind.hidden=false; ind.removeAttribute('hidden'); }
|
||
else { ind.hidden=true; ind.setAttribute('hidden',''); }
|
||
}
|
||
const dot=$('workspacePrefsDot');
|
||
if(dot){
|
||
if(S.showHiddenWorkspaceFiles){ dot.hidden=false; dot.removeAttribute('hidden'); }
|
||
else { dot.hidden=true; dot.setAttribute('hidden',''); }
|
||
}
|
||
}
|
||
function toggleWorkspaceHiddenFiles(value){
|
||
S.showHiddenWorkspaceFiles=!!value;
|
||
try{localStorage.setItem('hermes-workspace-show-hidden-files',S.showHiddenWorkspaceFiles?'1':'0');}catch(_){}
|
||
_syncWorkspaceHiddenToggle();
|
||
renderFileTree();
|
||
}
|
||
try{S.showHiddenWorkspaceFiles=localStorage.getItem('hermes-workspace-show-hidden-files')==='1';}catch(_){}
|
||
|
||
// ── Workspace preferences kebab menu (#1793 UX refinement) ───────────────
|
||
// The "Show hidden files" toggle used to live as a permanent inline row
|
||
// below the breadcrumb bar. That ate ~32px of vertical space on every
|
||
// panel view (root, subdir, file preview), even though the toggle is a
|
||
// set-once preference — most users flip it once or never. Moving the
|
||
// control into a kebab dropdown reclaims the space; the small "(hidden
|
||
// files visible)" indicator on the heading reflects the non-default state
|
||
// so the affordance isn't lost.
|
||
let _workspacePrefsMenu = null;
|
||
let _workspacePrefsAnchor = null;
|
||
function _closeWorkspacePrefsMenu(){
|
||
if(_workspacePrefsMenu){ _workspacePrefsMenu.remove(); _workspacePrefsMenu=null; }
|
||
if(_workspacePrefsAnchor){
|
||
_workspacePrefsAnchor.classList.remove('active');
|
||
_workspacePrefsAnchor.setAttribute('aria-expanded','false');
|
||
_workspacePrefsAnchor=null;
|
||
}
|
||
}
|
||
function _positionWorkspacePrefsMenu(anchorEl){
|
||
if(!_workspacePrefsMenu||!anchorEl) return;
|
||
const rect=anchorEl.getBoundingClientRect();
|
||
const menuW=Math.min(260, Math.max(220, _workspacePrefsMenu.scrollWidth||220));
|
||
let left=rect.right-menuW;
|
||
if(left<8) left=8;
|
||
if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
|
||
let top=rect.bottom+6;
|
||
const menuH=_workspacePrefsMenu.offsetHeight||0;
|
||
if(top+menuH>window.innerHeight-8 && rect.top>menuH+12) top=rect.top-menuH-6;
|
||
if(top<8) top=8;
|
||
_workspacePrefsMenu.style.left=left+'px';
|
||
_workspacePrefsMenu.style.top=top+'px';
|
||
}
|
||
function _buildWorkspacePrefsMenu(){
|
||
const menu=document.createElement('div');
|
||
menu.className='workspace-prefs-menu open';
|
||
menu.setAttribute('role','menu');
|
||
// The checkbox keeps id="workspaceShowHiddenFiles" so existing call
|
||
// sites (and the existing test_issue1793_file_tree_cruft_filter test)
|
||
// can find it the same way as before. Only the parent container moves.
|
||
const labelTxt = (typeof t==='function' ? t('workspace_show_hidden_files') : 'Show hidden files');
|
||
const descTxt = (typeof t==='function' ? t('workspace_show_hidden_files_desc') : 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.');
|
||
const row=document.createElement('label');
|
||
row.className='workspace-prefs-item';
|
||
row.setAttribute('role','menuitemcheckbox');
|
||
row.innerHTML=
|
||
'<input type="checkbox" id="workspaceShowHiddenFiles" '+
|
||
'onchange="toggleWorkspaceHiddenFiles(this.checked)">'+
|
||
'<span class="workspace-prefs-copy">'+
|
||
'<span class="workspace-prefs-name">'+esc(labelTxt)+'</span>'+
|
||
'<span class="workspace-prefs-meta">'+esc(descTxt)+'</span>'+
|
||
'</span>';
|
||
const cb=row.querySelector('input');
|
||
if(cb) cb.checked=!!S.showHiddenWorkspaceFiles;
|
||
menu.appendChild(row);
|
||
return menu;
|
||
}
|
||
function toggleWorkspacePrefsMenu(e){
|
||
if(e&&e.preventDefault) e.preventDefault();
|
||
if(e&&e.stopPropagation) e.stopPropagation();
|
||
// Anchor preference: the kebab button. The indicator chip can also open
|
||
// the same menu (click on "(hidden visible)"), but anchor positioning
|
||
// always references the kebab so the menu lands in the same place.
|
||
const anchor=$('btnWorkspacePrefs')||(e&&e.currentTarget)||null;
|
||
if(_workspacePrefsMenu&&_workspacePrefsAnchor===anchor){ _closeWorkspacePrefsMenu(); return; }
|
||
_closeWorkspacePrefsMenu();
|
||
const menu=_buildWorkspacePrefsMenu();
|
||
document.body.appendChild(menu);
|
||
_workspacePrefsMenu=menu;
|
||
_workspacePrefsAnchor=anchor;
|
||
if(anchor){ anchor.classList.add('active'); anchor.setAttribute('aria-expanded','true'); }
|
||
_positionWorkspacePrefsMenu(anchor);
|
||
}
|
||
document.addEventListener('click',e=>{
|
||
if(!_workspacePrefsMenu) return;
|
||
if(_workspacePrefsMenu.contains(e.target)) return;
|
||
if(_workspacePrefsAnchor&&_workspacePrefsAnchor.contains(e.target)) return;
|
||
// Indicator chip is also an opener — clicking it should toggle, not close.
|
||
const ind=$('workspaceHiddenIndicator');
|
||
if(ind&&ind.contains(e.target)) return;
|
||
_closeWorkspacePrefsMenu();
|
||
});
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='Escape'&&_workspacePrefsMenu) _closeWorkspacePrefsMenu();
|
||
});
|
||
window.addEventListener('resize',()=>{
|
||
if(_workspacePrefsMenu&&_workspacePrefsAnchor) _positionWorkspacePrefsMenu(_workspacePrefsAnchor);
|
||
});
|
||
|
||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',_syncWorkspaceHiddenToggle);
|
||
else _syncWorkspaceHiddenToggle();
|
||
|
||
function bindWorkspaceHeadingActions(){
|
||
const heading=$('workspacePanelHeading');
|
||
if(!heading||heading.dataset.bound==='1')return;
|
||
heading.dataset.bound='1';
|
||
const goRoot=()=>{
|
||
if(S.session&&S.session.workspace) loadDir('.');
|
||
};
|
||
heading.onclick=goRoot;
|
||
heading.onkeydown=(e)=>{
|
||
if(!(S.session&&S.session.workspace)) return;
|
||
if(e.key==='Enter'||e.key===' '){
|
||
e.preventDefault();
|
||
goRoot();
|
||
}
|
||
};
|
||
heading.oncontextmenu=(e)=>{
|
||
if(!(S.session&&S.session.workspace)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_showWorkspaceRootContextMenu(e);
|
||
};
|
||
_syncWorkspaceHeadingState();
|
||
}
|
||
|
||
function _syncWorkspaceHeadingState(){
|
||
const heading=$('workspacePanelHeading');
|
||
if(!heading) return;
|
||
const enabled=!!(S.session&&S.session.workspace);
|
||
heading.classList.toggle('workspace-panel-heading--enabled',enabled);
|
||
if(enabled){
|
||
heading.setAttribute('role','button');
|
||
heading.setAttribute('tabindex','0');
|
||
heading.setAttribute('aria-disabled','false');
|
||
heading.title='Workspace root';
|
||
} else {
|
||
heading.removeAttribute('role');
|
||
heading.removeAttribute('tabindex');
|
||
heading.setAttribute('aria-disabled','true');
|
||
heading.title=t('no_workspace');
|
||
}
|
||
}
|
||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',bindWorkspaceHeadingActions);
|
||
else bindWorkspaceHeadingActions();
|
||
|
||
function _workspaceContextMenuItem(label, onClick, opts={}){
|
||
const item=document.createElement('div');
|
||
item.textContent=label;
|
||
item.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:'+(opts.danger?'var(--error,#e94560)':'var(--text)')+';';
|
||
item.onmouseenter=()=>item.style.background='var(--hover-bg)';
|
||
item.onmouseleave=()=>item.style.background='';
|
||
item.onclick=onClick;
|
||
return item;
|
||
}
|
||
|
||
function _copyTextWithFallback(text, successMsg, failurePrefix){
|
||
const done=()=>showToast(successMsg);
|
||
const fail=(err)=>showToast(failurePrefix+(err&&err.message?err.message:String(err||'')));
|
||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||
return navigator.clipboard.writeText(text).then(done).catch(err=>{
|
||
const ta=document.createElement('textarea');
|
||
ta.value=text;
|
||
ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
let copied=false;
|
||
try{copied=document.execCommand('copy');}catch(_){}
|
||
ta.remove();
|
||
if(copied) done(); else fail(err);
|
||
});
|
||
}
|
||
const ta=document.createElement('textarea');
|
||
ta.value=text;
|
||
ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
let copied=false;
|
||
try{copied=document.execCommand('copy');}catch(err){ta.remove();fail(err);return Promise.resolve();}
|
||
ta.remove();
|
||
if(copied) done(); else fail('clipboard unavailable');
|
||
return Promise.resolve();
|
||
}
|
||
|
||
function _showWorkspaceRootContextMenu(e){
|
||
document.querySelectorAll('.file-ctx-menu').forEach(el=>el.remove());
|
||
const menu=document.createElement('div');
|
||
menu.className='file-ctx-menu workspace-root-ctx-menu';
|
||
menu.style.cssText='position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:160px;box-shadow:0 4px 16px rgba(0,0,0,.35);';
|
||
const vw=window.innerWidth,vh=window.innerHeight;
|
||
menu.style.left=(e.clientX+160>vw?e.clientX-170:e.clientX)+'px';
|
||
menu.style.top=(e.clientY+80>vh?e.clientY-80:e.clientY)+'px';
|
||
|
||
menu.appendChild(_workspaceContextMenuItem(t('reveal_in_finder'),async()=>{
|
||
menu.remove();
|
||
try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});}
|
||
catch(err){showToast(t('reveal_failed')+(err.message||err));}
|
||
}));
|
||
|
||
menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{
|
||
menu.remove();
|
||
try{
|
||
const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});
|
||
await _copyTextWithFallback((r&&r.path)||'.',t('path_copied'),t('path_copy_failed'));
|
||
}catch(err){showToast(t('path_copy_failed')+(err.message||err));}
|
||
}));
|
||
|
||
document.body.appendChild(menu);
|
||
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
|
||
setTimeout(()=>document.addEventListener('click',dismiss),0);
|
||
}
|
||
|
||
// 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;
|
||
// Show empty-state when no workspace is set or the directory is empty (#703)
|
||
const emptyEl=$('wsEmptyState');
|
||
const hasWorkspace=!!(S.session&&S.session.workspace);
|
||
if(!hasWorkspace){
|
||
if(emptyEl){emptyEl.textContent=t('workspace_empty_no_path');emptyEl.style.display='flex';}
|
||
box.style.display='none';
|
||
return;
|
||
}
|
||
if(emptyEl) emptyEl.style.display='none';
|
||
box.style.display='';
|
||
const visibleEntries=_visibleWorkspaceEntries(S.entries);
|
||
if(!visibleEntries.length){
|
||
if(emptyEl){emptyEl.textContent=t('workspace_empty_dir');emptyEl.style.display='flex';}
|
||
return;
|
||
}
|
||
_renderTreeItems(box, visibleEntries, 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';
|
||
el.setAttribute('draggable','true');
|
||
el.oncontextmenu=(e)=>{e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);};
|
||
el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';};
|
||
|
||
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);
|
||
}else{
|
||
// Keep file icons aligned with sibling directories that occupy this
|
||
// slot with the expand/collapse toggle. #2554
|
||
const spacer=document.createElement('span');
|
||
spacer.className='file-tree-toggle-placeholder';
|
||
spacer.setAttribute('aria-hidden','true');
|
||
el.appendChild(spacer);
|
||
}
|
||
|
||
// 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;
|
||
// Tooltip only on FILES — dblclick renames them. On directories, dblclick
|
||
// navigates into the folder; rename lives in the right-click context menu
|
||
// (the "Double-click to rename" hint here would be misleading). #1710.
|
||
if(item.type!=='dir')nameEl.title=t('double_click_rename');
|
||
// Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a
|
||
// double-click can cancel it and trigger rename instead. Without the debounce, the
|
||
// click bubbles to el.onclick before dblclick can fire — that's #1698. Without the
|
||
// restored activation, single-click on the filename does nothing — that's #1707.
|
||
let _nameClickTimer=null;
|
||
nameEl.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
|
||
_nameClickTimer=setTimeout(()=>{
|
||
_nameClickTimer=null;
|
||
// Delegate to the row's existing single-click handler (openFile / dir toggle).
|
||
if(typeof el.onclick==='function')el.onclick(e);
|
||
},300);
|
||
};
|
||
nameEl.ondblclick=(e)=>{
|
||
e.stopPropagation();
|
||
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
|
||
// 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);
|
||
// Update expanded dirs cache key if renaming a directory
|
||
if(item.type==='dir'&&S._expandedDirs){
|
||
S._expandedDirs.delete(item.path);
|
||
const parent=item.path.includes('/')?item.path.substring(0,item.path.lastIndexOf('/')):'.';
|
||
const newPath=parent==='.'?newName:parent+'/'+newName;
|
||
S._expandedDirs.add(newPath);
|
||
if(S._dirCache[item.path]){S._dirCache[newPath]=S._dirCache[item.path];delete S._dirCache[item.path];}
|
||
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
|
||
}
|
||
// 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'){
|
||
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
|
||
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 and directories
|
||
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);
|
||
}else if(item.type==='dir'){
|
||
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 deleteWorkspaceDir(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=_visibleWorkspaceEntries(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 deleteWorkspaceDir(relPath, name){
|
||
if(!S.session)return;
|
||
const ok=await showConfirmDialog({title:t('delete_dir_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});
|
||
if(!ok)return;
|
||
try{
|
||
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,recursive:true})});
|
||
showToast(t('deleted')+name);
|
||
// Remove from expanded dirs cache
|
||
if(S._expandedDirs){S._expandedDirs.delete(relPath);if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();}
|
||
delete S._dirCache[relPath];
|
||
await loadDir(S.currentDir);
|
||
}catch(e){setStatus(t('delete_failed')+e.message);}
|
||
}
|
||
|
||
function _showFileContextMenu(e, item){
|
||
document.querySelectorAll('.file-ctx-menu').forEach(el=>el.remove());
|
||
const menu=document.createElement('div');
|
||
menu.className='file-ctx-menu';
|
||
menu.style.cssText='position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.35);';
|
||
// Keep menu within viewport
|
||
const vw=window.innerWidth,vh=window.innerHeight;
|
||
menu.style.left=(e.clientX+140>vw?e.clientX-150:e.clientX)+'px';
|
||
menu.style.top=(e.clientY+100>vh?e.clientY-100:e.clientY)+'px';
|
||
|
||
// Rename
|
||
const renameItem=document.createElement('div');
|
||
renameItem.textContent=t('rename_title');
|
||
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)';
|
||
renameItem.onmouseleave=()=>renameItem.style.background='';
|
||
renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);};
|
||
menu.appendChild(renameItem);
|
||
|
||
// Reveal in File Manager
|
||
const revealItem=document.createElement('div');
|
||
revealItem.textContent=t('reveal_in_finder');
|
||
revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||
revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)';
|
||
revealItem.onmouseleave=()=>revealItem.style.background='';
|
||
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}};
|
||
menu.appendChild(revealItem);
|
||
|
||
// Copy file path — resolves the absolute on-disk path on the server (so the
|
||
// user gets the full /home/.../workspace/foo.py rather than the relative
|
||
// path the file tree shows) and writes it to the OS clipboard. Useful for
|
||
// pasting into terminals, editors, or other apps without taking the slower
|
||
// Reveal-in-Finder round trip.
|
||
const copyPathItem=document.createElement('div');
|
||
copyPathItem.textContent=t('copy_file_path');
|
||
copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||
copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)';
|
||
copyPathItem.onmouseleave=()=>copyPathItem.style.background='';
|
||
copyPathItem.onclick=async()=>{
|
||
menu.remove();
|
||
try{
|
||
const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});
|
||
const abs=(r&&r.path)||item.path;
|
||
try{
|
||
await navigator.clipboard.writeText(abs);
|
||
showToast(t('path_copied'));
|
||
}catch(clipErr){
|
||
// Fallback for browsers where Clipboard API is gated (older Safari,
|
||
// non-secure contexts). Use the legacy execCommand path against a
|
||
// hidden textarea — this is the same pattern boot.js uses for the
|
||
// "Copy" buttons on code blocks.
|
||
const ta=document.createElement('textarea');
|
||
ta.value=abs;
|
||
ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
let copied=false;
|
||
try{copied=document.execCommand('copy');}catch(_){}
|
||
ta.remove();
|
||
if(copied) showToast(t('path_copied'));
|
||
else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr)));
|
||
}
|
||
}catch(err){
|
||
showToast(t('path_copy_failed')+(err.message||err));
|
||
}
|
||
};
|
||
menu.appendChild(copyPathItem);
|
||
|
||
// Divider + Delete
|
||
const sep=document.createElement('hr');
|
||
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
|
||
menu.appendChild(sep);
|
||
const delItem=document.createElement('div');
|
||
delItem.textContent=t('delete_title');
|
||
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
|
||
delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)';
|
||
delItem.onmouseleave=()=>delItem.style.background='';
|
||
delItem.onclick=()=>{menu.remove();if(item.type==='dir')deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);};
|
||
menu.appendChild(delItem);
|
||
|
||
document.body.appendChild(menu);
|
||
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
|
||
setTimeout(()=>document.addEventListener('click',dismiss),0);
|
||
}
|
||
|
||
async function _inlineRenameFileItem(item){
|
||
if(!S.session)return;
|
||
// Pre-fill the input with the current name and select just the stem
|
||
// (everything before the last '.') so the user can immediately retype the
|
||
// basename while preserving the extension — matches macOS Finder. For
|
||
// directories or names with no '.', the helper selects the full value.
|
||
// `selectStem` also handles dotfiles ('.gitignore') by full-selecting.
|
||
const newName=await showPromptDialog({
|
||
message:t('rename_prompt'),
|
||
value:item.name,
|
||
confirmLabel:t('rename_title'),
|
||
selectStem:item.type!=='dir',
|
||
selectAll:item.type==='dir'
|
||
});
|
||
if(!newName||newName===item.name)return;
|
||
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);
|
||
// Update expanded dirs cache key if renaming a directory
|
||
if(item.type==='dir'&&S._expandedDirs){
|
||
S._expandedDirs.delete(item.path);
|
||
const parent=item.path.includes('/')?item.path.substring(0,item.path.lastIndexOf('/')):'.';
|
||
const newPath=parent==='.'?newName:parent+'/'+newName;
|
||
S._expandedDirs.add(newPath);
|
||
if(S._dirCache[item.path]){S._dirCache[newPath]=S._dirCache[item.path];delete S._dirCache[item.path];}
|
||
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
|
||
}
|
||
delete S._dirCache[S.currentDir];
|
||
await loadDir(S.currentDir);
|
||
}catch(err){showToast(t('rename_failed')+err.message);}
|
||
}
|
||
|
||
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').onclick();
|
||
await loadDir(S.currentDir);
|
||
}catch(e){setStatus(t('delete_failed')+e.message);}
|
||
}
|
||
|
||
async function promptNewFile(){
|
||
// If no active session but a default workspace is configured, auto-create
|
||
// a session bound to it so workspace actions work on the blank new-chat page.
|
||
if(!S.session){
|
||
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||
if(!ws) return;
|
||
try{
|
||
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
|
||
}catch(e){setStatus(t('create_failed')+e.message);return;}
|
||
}
|
||
if(!S.session)return;
|
||
const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
|
||
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(){
|
||
// Same auto-create-session logic as promptNewFile for the blank page.
|
||
if(!S.session){
|
||
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||
if(!ws) return;
|
||
try{
|
||
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
|
||
}catch(e){setStatus(t('folder_create_failed')+e.message);return;}
|
||
}
|
||
if(!S.session)return;
|
||
const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
|
||
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);
|
||
// Offer to add the new folder as a space (#782)
|
||
const absPath=S.session.workspace?((S.currentDir==='.'?S.session.workspace:S.session.workspace+'/'+S.currentDir)+'/'+name.trim()):null;
|
||
if(absPath){
|
||
const addAsSpace=await showConfirmDialog({
|
||
title:t('folder_add_as_space_title'),
|
||
message:t('folder_add_as_space_msg'),
|
||
confirmLabel:t('folder_add_as_space_btn'),
|
||
focusCancel:true
|
||
});
|
||
if(addAsSpace){
|
||
try{
|
||
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:absPath})});
|
||
if(typeof _workspaceList!=='undefined')_workspaceList=data.workspaces||_workspaceList||[];
|
||
if(typeof renderWorkspacesPanel==='function')renderWorkspacesPanel(_workspaceList);
|
||
showToast(t('workspace_added'));
|
||
}catch(e2){setStatus((t('error_prefix')||'Error: ')+e2.message);}
|
||
}
|
||
}
|
||
}catch(e){setStatus(t('folder_create_failed')+e.message);}
|
||
}
|
||
|
||
function renderTray(){ // non-media files use paperclip chip
|
||
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';
|
||
const mediaKind=_mediaKindForName(f.name);
|
||
if(_IMAGE_EXTS.test(f.name)||mediaKind==='audio'||mediaKind==='video'){
|
||
const blobUrl=URL.createObjectURL(f);
|
||
chip.className='attach-chip attach-chip--media attach-chip--'+mediaKind; // attach-chip--audio attach-chip--video
|
||
chip.dataset.blobUrl=blobUrl;
|
||
if(mediaKind==='image'){
|
||
chip.innerHTML=`<img class="attach-thumb" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||
} else if(_SVG_EXTS.test(f.name)){
|
||
chip.innerHTML=`<img class="attach-thumb attach-thumb--svg" src="${esc(blobUrl)}" alt="${esc(f.name)}" title="${esc(f.name)}"><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||
} else if(mediaKind==='audio'){
|
||
chip.innerHTML=`<span class="attach-chip-media">🎵 ${esc(f.name)}</span><audio controls preload="metadata" src="${esc(blobUrl)}"></audio><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||
} else if(mediaKind==='video'){
|
||
chip.innerHTML=`<span class="attach-chip-media">🎬 ${esc(f.name)}</span><video controls preload="metadata" src="${esc(blobUrl)}"></video><button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||
}
|
||
} else {
|
||
chip.innerHTML=`${li('paperclip',12)} ${esc(f.name)} <button title="${t('remove_title')}">${li('x',12)}</button>`;
|
||
}
|
||
chip.querySelector('button').onclick=()=>{
|
||
// Revoke blob URL to avoid memory leak before removing
|
||
if(chip.dataset.blobUrl) URL.revokeObjectURL(chip.dataset.blobUrl);
|
||
S.pendingFiles.splice(i,1);renderTray();
|
||
};
|
||
tray.appendChild(chip);
|
||
});
|
||
}
|
||
function _uploadTooLargeMessage(file){
|
||
const fileSizeMb=Math.ceil(((file&&file.size)||0)/1024/1024);
|
||
return t('upload_too_large',MAX_UPLOAD_MB,fileSizeMb);
|
||
}
|
||
function _showUploadTooLarge(file){
|
||
const message=`${t('upload_failed')}${file&&file.name?file.name:'file'} \u2014 ${_uploadTooLargeMessage(file)}`;
|
||
if(typeof setStatus==='function')setStatus(`\u274c ${message}`);
|
||
else if(typeof showToast==='function')showToast(message,5000,'error');
|
||
}
|
||
function addFiles(files){
|
||
for(const f of files){
|
||
if(f&&f.size>MAX_UPLOAD_BYTES){_showUploadTooLarge(f);continue;}
|
||
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;i<total;i++){
|
||
const f=S.pendingFiles[i];
|
||
try{
|
||
if(f&&f.size>MAX_UPLOAD_BYTES)throw new Error(_uploadTooLargeMessage(f));
|
||
const fd=new FormData();
|
||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||
const isArchive=_ARCHIVE_EXTS.test(f.name);
|
||
const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href;
|
||
const res=await fetch(url,{method:'POST',credentials:'include',body:fd});
|
||
if(_redirectIfUnauth(res)) return;
|
||
if(!res.ok){const err=await res.text();throw new Error(err);}
|
||
const data=await res.json();
|
||
if(data.error)throw new Error(data.error);
|
||
if(isArchive){
|
||
names.push({name: data.dest, path: data.dest, extracted: data.extracted});
|
||
if(typeof loadDir==='function')loadDir(S.currentDir||'.');
|
||
}else{
|
||
names.push({name: data.filename, path: data.path, mime: data.mime, size: data.size, is_image: !!data.is_image});
|
||
}
|
||
}catch(e){failures++;setStatus(`\u274c ${t('upload_failed')}${f.name} \u2014 ${e.message}`);}
|
||
bar.style.width=`${Math.round((i+1)/total*100)}%`;
|
||
}
|
||
barWrap.classList.remove('active');bar.style.width='0%';
|
||
S.pendingFiles=[];renderTray();
|
||
if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total));
|
||
// Show extraction summary
|
||
const extracted=names.filter(n=>n.extracted);
|
||
if(extracted.length)showToast(t('archive_extracted',extracted.reduce((s,n)=>s+n.extracted,0),extracted.length));
|
||
return names;
|
||
}
|