fix: live reasoning, tool progress, in-flight session recovery (#367)
* fix: preserve live session output across chat switches (cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5) * fix: preserve todo state after session reload (cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde) * fix: preserve live assistant anchor across rerenders * fix: stream live reasoning and tool progress * fix: recover inflight session state after reload * fix: add loadInflightState stub + CHANGELOG v0.50.21 - static/ui.js: add loadInflightState() function (currently returns null — the typeof guard in sessions.js means reload recovery works via the else-path attachLiveStream call; this stub satisfies the guard cleanly and documents the extension point for future localStorage-backed state) - CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949) --------- Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
@@ -10,10 +10,12 @@ async function send(){
|
||||
// If busy, queue the message instead of dropping it
|
||||
if(S.busy){
|
||||
if(text){
|
||||
MSG_QUEUE.push(text);
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
|
||||
$('msg').value='';autoResize();
|
||||
updateQueueBadge();
|
||||
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
|
||||
S.pendingFiles=[];renderTray();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +39,7 @@ async function send(){
|
||||
S.toolCalls=[]; // clear tool calls from previous turn
|
||||
clearLiveToolCards(); // clear any leftover live cards from last turn
|
||||
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true);
|
||||
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
|
||||
INFLIGHT[activeSid]={messages:[...S.messages],uploaded,toolCalls:[]};
|
||||
startApprovalPolling(activeSid);
|
||||
S.activeStreamId = null; // will be set after stream starts
|
||||
|
||||
@@ -81,7 +83,32 @@ async function send(){
|
||||
}
|
||||
|
||||
// Open SSE stream and render tokens live
|
||||
attachLiveStream(activeSid, streamId, uploaded);
|
||||
|
||||
}
|
||||
|
||||
const LIVE_STREAMS={};
|
||||
|
||||
function closeLiveStream(sessionId, streamId){
|
||||
const live=LIVE_STREAMS[sessionId];
|
||||
if(!live) return;
|
||||
if(streamId&&live.streamId!==streamId) return;
|
||||
try{live.source.close();}catch(_){ }
|
||||
delete LIVE_STREAMS[sessionId];
|
||||
}
|
||||
|
||||
function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!activeSid||!streamId) return;
|
||||
const reconnecting=!!options.reconnecting;
|
||||
closeLiveStream(activeSid);
|
||||
if(!INFLIGHT[activeSid]) INFLIGHT[activeSid]={messages:[...S.messages],uploaded:[...uploaded],toolCalls:[]};
|
||||
else {
|
||||
if(uploaded.length) INFLIGHT[activeSid].uploaded=[...uploaded];
|
||||
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
|
||||
}
|
||||
|
||||
let assistantText='';
|
||||
let reasoningText='';
|
||||
let assistantRow=null;
|
||||
let assistantBody=null;
|
||||
// Thinking tag patterns for streaming display
|
||||
@@ -90,8 +117,45 @@ async function send(){
|
||||
{open:'<|channel>thought\n',close:'<channel|>'}
|
||||
];
|
||||
|
||||
function _isActiveSession(){
|
||||
return !!(S.session&&S.session.session_id===activeSid);
|
||||
}
|
||||
function _closeSource(){
|
||||
closeLiveStream(activeSid, streamId);
|
||||
}
|
||||
function syncInflightAssistantMessage(){
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
if(!Array.isArray(inflight.messages)) inflight.messages=[];
|
||||
let assistantIdx=-1;
|
||||
for(let i=inflight.messages.length-1;i>=0;i--){
|
||||
const msg=inflight.messages[i];
|
||||
if(msg&&msg.role==='assistant'&&msg._live){assistantIdx=i;break;}
|
||||
}
|
||||
const ts=Date.now()/1000;
|
||||
if(assistantIdx>=0){
|
||||
inflight.messages[assistantIdx].content=assistantText;
|
||||
inflight.messages[assistantIdx].reasoning=reasoningText||undefined;
|
||||
inflight.messages[assistantIdx]._ts=inflight.messages[assistantIdx]._ts||ts;
|
||||
return;
|
||||
}
|
||||
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
|
||||
}
|
||||
function ensureAssistantRow(){
|
||||
if(assistantRow)return;
|
||||
if(!_isActiveSession()) return;
|
||||
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
|
||||
if(!assistantRow){
|
||||
const existing=$('msgInner').querySelector('.msg-row[data-live-assistant="1"]');
|
||||
if(existing){
|
||||
assistantRow=existing;
|
||||
assistantBody=existing.querySelector('.msg-body');
|
||||
}
|
||||
}
|
||||
if(assistantRow){
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
return;
|
||||
}
|
||||
|
||||
removeThinking();
|
||||
const tr=$('toolRunningRow');if(tr)tr.remove();
|
||||
$('emptyState').style.display='none';
|
||||
@@ -115,6 +179,7 @@ async function send(){
|
||||
// and hiding content still inside an open thinking block.
|
||||
function _streamDisplay(){
|
||||
const raw=assistantText;
|
||||
if(reasoningText) return raw;
|
||||
for(const {open,close} of _thinkPairs){
|
||||
// Trim leading whitespace before checking for the open tag — some models
|
||||
// (e.g. MiniMax) emit newlines before <think>.
|
||||
@@ -134,15 +199,52 @@ async function send(){
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
function _parseStreamState(){
|
||||
const raw=assistantText;
|
||||
if(reasoningText){
|
||||
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
|
||||
}
|
||||
for(const {open,close} of _thinkPairs){
|
||||
const trimmed=raw.trimStart();
|
||||
if(trimmed.startsWith(open)){
|
||||
const ci=trimmed.indexOf(close,open.length);
|
||||
if(ci!==-1){
|
||||
return {
|
||||
thinkingText: trimmed.slice(open.length, ci).trim(),
|
||||
displayText: trimmed.slice(ci+close.length).replace(/^\s+/,''),
|
||||
inThinking:false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
thinkingText: trimmed.slice(open.length).trim(),
|
||||
displayText:'',
|
||||
inThinking:true,
|
||||
};
|
||||
}
|
||||
if(open.startsWith(trimmed)){
|
||||
return {thinkingText:'', displayText:'', inThinking:true};
|
||||
}
|
||||
}
|
||||
return {thinkingText:'', displayText:raw, inThinking:false};
|
||||
}
|
||||
function _renderLiveThinking(parsed){
|
||||
const text=(parsed&&parsed.thinkingText)||'';
|
||||
if(text||(parsed&&parsed.inThinking)){
|
||||
if(typeof updateThinking==='function') updateThinking(text||'Thinking…');
|
||||
else appendThinking();
|
||||
return;
|
||||
}
|
||||
removeThinking();
|
||||
}
|
||||
function _scheduleRender(){
|
||||
if(_renderPending) return;
|
||||
_renderPending=true;
|
||||
requestAnimationFrame(()=>{
|
||||
_renderPending=false;
|
||||
const parsed=_parseStreamState();
|
||||
_renderLiveThinking(parsed);
|
||||
if(assistantBody){
|
||||
const txt=_streamDisplay();
|
||||
const isThinking=!txt&&assistantText.length>0;
|
||||
assistantBody.innerHTML=txt?renderMd(txt):(isThinking?'<span style="color:var(--muted);font-size:13px">Thinking\u2026</span>':'');
|
||||
assistantBody.innerHTML=parsed.displayText?renderMd(parsed.displayText):'';
|
||||
}
|
||||
scrollIfPinned();
|
||||
});
|
||||
@@ -153,17 +255,59 @@ async function send(){
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
const d=JSON.parse(e.data);
|
||||
assistantText+=d.text;
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
|
||||
ensureAssistantRow();
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
source.addEventListener('reasoning',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
reasoningText += d.text || '';
|
||||
syncInflightAssistantMessage();
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_scheduleRender();
|
||||
});
|
||||
|
||||
source.addEventListener('tool',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false, tid:d.tid||`live-${Date.now()}-${Math.random().toString(36).slice(2,8)}`};
|
||||
if(!Array.isArray(INFLIGHT[activeSid].toolCalls)) INFLIGHT[activeSid].toolCalls=[];
|
||||
INFLIGHT[activeSid].toolCalls.push(tc);
|
||||
S.toolCalls=INFLIGHT[activeSid].toolCalls;
|
||||
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
removeThinking();
|
||||
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
|
||||
S.toolCalls.push(tc);
|
||||
appendLiveToolCard(tc);
|
||||
scrollIfPinned();
|
||||
});
|
||||
|
||||
source.addEventListener('tool_complete',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
const inflight=INFLIGHT[activeSid];
|
||||
if(!inflight) return;
|
||||
if(!Array.isArray(inflight.toolCalls)) inflight.toolCalls=[];
|
||||
let tc=null;
|
||||
for(let i=inflight.toolCalls.length-1;i>=0;i--){
|
||||
const cur=inflight.toolCalls[i];
|
||||
if(cur&&cur.done===false&&(!d.name||cur.name===d.name)){
|
||||
tc=cur;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!tc){
|
||||
tc={name:d.name||'tool', preview:d.preview||'', args:d.args||{}, snippet:'', done:true};
|
||||
inflight.toolCalls.push(tc);
|
||||
}
|
||||
tc.preview=d.preview||tc.preview||'';
|
||||
tc.args=d.args||tc.args||{};
|
||||
tc.done=true;
|
||||
tc.is_error=!!d.is_error;
|
||||
if(d.duration!==undefined) tc.duration=d.duration;
|
||||
S.toolCalls=inflight.toolCalls;
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
appendLiveToolCard(tc);
|
||||
scrollIfPinned();
|
||||
});
|
||||
|
||||
@@ -308,10 +308,11 @@ async function cronDelete(id) {
|
||||
function loadTodos() {
|
||||
const panel = $('todoPanel');
|
||||
if (!panel) return;
|
||||
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
|
||||
// Parse the most recent todo state from message history
|
||||
let todos = [];
|
||||
for (let i = S.messages.length - 1; i >= 0; i--) {
|
||||
const m = S.messages[i];
|
||||
for (let i = sourceMessages.length - 1; i >= 0; i--) {
|
||||
const m = sourceMessages[i];
|
||||
if (m && m.role === 'tool') {
|
||||
try {
|
||||
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
|
||||
|
||||
@@ -11,7 +11,7 @@ const ICONS={
|
||||
};
|
||||
|
||||
async function newSession(flash){
|
||||
MSG_QUEUE.length=0;updateQueueBadge();
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
// Use profile default workspace for new sessions after a profile switch (one-shot),
|
||||
@@ -20,9 +20,19 @@ async function newSession(flash){
|
||||
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
if(flash)S.session._flash=true;
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
syncTopbar();await loadDir('.');renderMessages();
|
||||
// Reset per-session visual state: a fresh chat is idle even if another
|
||||
// conversation is still streaming in the background.
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
updateSendBtn();
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
updateQueueBadge(S.session.session_id);
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
// don't call renderSessionList here - callers do it when needed
|
||||
}
|
||||
|
||||
@@ -30,40 +40,74 @@ async function loadSession(sid){
|
||||
stopApprovalPolling();hideApprovalCard();
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
S.session=data.session;
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
|
||||
data.session.messages=(data.session.messages||[]).filter(m=>{
|
||||
if(!m||!m.role)return false;
|
||||
if(m.role==='tool')return false;
|
||||
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
|
||||
return true;
|
||||
});
|
||||
const activeStreamId=data.session.active_stream_id||null;
|
||||
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
|
||||
const stored=loadInflightState(sid, activeStreamId);
|
||||
if(stored){
|
||||
INFLIGHT[sid]={
|
||||
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[...(data.session.messages||[])],
|
||||
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[...(data.session.pending_attachments||[])],
|
||||
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
||||
reattach:true,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Keep raw session.messages intact so side panels (e.g. Todos) can still
|
||||
// reconstruct state from tool outputs after reload. Visible transcript rows
|
||||
// are filtered later by renderMessages().
|
||||
if(INFLIGHT[sid]){
|
||||
S.messages=INFLIGHT[sid].messages;
|
||||
// Restore live tool cards for this in-flight session
|
||||
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||||
S.busy=true;
|
||||
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
clearLiveToolCards();
|
||||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
syncTopbar();await loadDir('.');renderMessages();appendThinking();
|
||||
setBusy(true);setComposerStatus('');
|
||||
startApprovalPolling(sid);
|
||||
S.activeStreamId=activeStreamId;
|
||||
const _cb=$('btnCancel');if(_cb&&activeStreamId)_cb.style.display='inline-flex';
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
}else{
|
||||
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
|
||||
updateQueueBadge(sid);
|
||||
S.messages=data.session.messages||[];
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||
// Reset per-session visual state: the viewed session is idle even if another
|
||||
// session's stream is still running in the background.
|
||||
// We directly update the DOM instead of calling setBusy(false), because
|
||||
// setBusy(false) drains MSG_QUEUE which we don't want here.
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
updateSendBtn();
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
clearLiveToolCards();
|
||||
syncTopbar();await loadDir('.');renderMessages();highlightCode();
|
||||
if(activeStreamId){
|
||||
S.busy=true;
|
||||
S.activeStreamId=activeStreamId;
|
||||
updateSendBtn();
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='inline-flex';
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
syncTopbar();renderMessages();appendThinking();loadDir('.');
|
||||
updateQueueBadge(sid);
|
||||
startApprovalPolling(sid);
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, data.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
}else{
|
||||
// Reset per-session visual state: the viewed session is idle even if another
|
||||
// session's stream is still running in the background.
|
||||
// We directly update the DOM instead of calling setBusy(false), because
|
||||
// setBusy(false) drains the viewed session's queued follow-up turns.
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
updateSendBtn();
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
setStatus('');
|
||||
setComposerStatus('');
|
||||
updateQueueBadge(sid);
|
||||
syncTopbar();renderMessages();highlightCode();loadDir('.');
|
||||
}
|
||||
}
|
||||
// Sync context usage indicator from session data
|
||||
const _s=S.session;
|
||||
|
||||
126
static/ui.js
126
static/ui.js
@@ -1,7 +1,28 @@
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
|
||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||
const MSG_QUEUE=[]; // messages queued while a request is in-flight
|
||||
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||||
const $=id=>document.getElementById(id);
|
||||
function _getSessionQueue(sid, create=false){
|
||||
if(!sid) return [];
|
||||
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
||||
return SESSION_QUEUES[sid]||[];
|
||||
}
|
||||
function queueSessionMessage(sid, payload){
|
||||
if(!sid||!payload) return 0;
|
||||
const q=_getSessionQueue(sid,true);
|
||||
q.push(payload);
|
||||
return q.length;
|
||||
}
|
||||
function shiftQueuedSessionMessage(sid){
|
||||
const q=_getSessionQueue(sid,false);
|
||||
if(!q.length) return null;
|
||||
const next=q.shift();
|
||||
if(!q.length) delete SESSION_QUEUES[sid];
|
||||
return next;
|
||||
}
|
||||
function getQueuedSessionCount(sid){
|
||||
return _getSessionQueue(sid,false).length;
|
||||
}
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||
@@ -513,28 +534,37 @@ function setBusy(v){
|
||||
setComposerStatus('');
|
||||
// Always hide Cancel button when not busy
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
updateQueueBadge();
|
||||
// Drain one queued message after UI settles
|
||||
if(MSG_QUEUE.length>0){
|
||||
const next=MSG_QUEUE.shift();
|
||||
updateQueueBadge();
|
||||
setTimeout(()=>{ $('msg').value=next; send(); }, 120);
|
||||
const sid=S.session&&S.session.session_id;
|
||||
updateQueueBadge(sid);
|
||||
// Drain one queued message for the currently viewed session after UI settles
|
||||
const next=sid?shiftQueuedSessionMessage(sid):null;
|
||||
if(next){
|
||||
updateQueueBadge(sid);
|
||||
setTimeout(()=>{
|
||||
$('msg').value=next.text||'';
|
||||
S.pendingFiles=Array.isArray(next.files)?[...next.files]:[];
|
||||
autoResize();
|
||||
renderTray();
|
||||
send();
|
||||
},120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateQueueBadge(){
|
||||
function updateQueueBadge(sessionId){
|
||||
const sid=sessionId||(S.session&&S.session.session_id);
|
||||
const count=sid?getQueuedSessionCount(sid):0;
|
||||
let badge=$('queueBadge');
|
||||
if(MSG_QUEUE.length>0){
|
||||
if(count>0){
|
||||
if(!badge){
|
||||
badge=document.createElement('div');
|
||||
badge.id='queueBadge';
|
||||
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`;
|
||||
} else {
|
||||
if(badge) badge.remove();
|
||||
badge.textContent=count===1?'1 message queued':`${count} messages queued`;
|
||||
} else if(badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
||||
@@ -714,11 +744,11 @@ async function refreshSession() {
|
||||
try {
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||
S.session = data.session;
|
||||
S.messages = (data.session.messages || []).filter(m => {
|
||||
if (!m || !m.role || m.role === 'tool') return false;
|
||||
if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; }
|
||||
return true;
|
||||
});
|
||||
S.messages = data.session.messages || [];
|
||||
const pendingMsg=getPendingSessionMessage(data.session);
|
||||
if(pendingMsg) S.messages.push(pendingMsg);
|
||||
S.activeStreamId=data.session.active_stream_id||null;
|
||||
|
||||
syncTopbar(); renderMessages();
|
||||
showToast('Conversation refreshed');
|
||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||
@@ -764,12 +794,46 @@ async function applyUpdates(){
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingSessionMessage(session){
|
||||
const text=String(session?.pending_user_message||'').trim();
|
||||
if(!text) return null;
|
||||
const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[];
|
||||
const messages=Array.isArray(session?.messages)?session.messages:[];
|
||||
const lastUser=[...messages].reverse().find(m=>m&&m.role==='user');
|
||||
if(lastUser){
|
||||
const lastText=String(msgContent(lastUser)||'').trim();
|
||||
if(lastText===text){
|
||||
if(attachments.length&&!lastUser.attachments?.length) lastUser.attachments=attachments;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
role:'user',
|
||||
content:text,
|
||||
attachments:attachments.length?attachments:undefined,
|
||||
_ts:session?.pending_started_at||Date.now()/1000,
|
||||
_pending:true,
|
||||
};
|
||||
}
|
||||
// loadInflightState — retrieve in-memory inflight state for a session.
|
||||
// Called by loadSession() when active_stream_id is set on the server session
|
||||
// but no INFLIGHT[sid] entry exists (e.g. after a session switch back).
|
||||
// Returns the stored state dict or null. The else-path in loadSession handles
|
||||
// page reloads directly via attachLiveStream when this returns null.
|
||||
function loadInflightState(sid, streamId) {
|
||||
// In-memory store: only survives within the same page load.
|
||||
// If INFLIGHT[sid] exists but the caller already checked !INFLIGHT[sid],
|
||||
// this won't be reached. Return null — the else path handles page reloads.
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -915,6 +979,7 @@ function renderMessages(){
|
||||
}
|
||||
const row=document.createElement('div');row.className='msg-row';
|
||||
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
|
||||
if(m._live) row.setAttribute('data-live-assistant','1');
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length)
|
||||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
|
||||
@@ -1357,12 +1422,29 @@ function renderKatexBlocks(){
|
||||
});
|
||||
}
|
||||
|
||||
function appendThinking(){
|
||||
$('emptyState').style.display='none';
|
||||
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
|
||||
row.innerHTML=`<div class="msg-role assistant"><div class="role-icon assistant">H</div>Hermes</div><div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||
$('msgInner').appendChild(row);scrollToBottom();
|
||||
function _thinkingMarkup(text=''){
|
||||
const _bn=window._botName||'Hermes';
|
||||
const icon=esc(_bn.charAt(0).toUpperCase());
|
||||
const label=esc(_bn);
|
||||
const body=(text&&String(text).trim())
|
||||
? `<div class="thinking-card open"><div class="thinking-card-header"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span></div><div class="thinking-card-body"><pre>${esc(String(text).trim())}</pre></div></div>`
|
||||
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||
return `<div class="msg-role assistant"><div class="role-icon assistant">${icon}</div>${label}</div>${body}`;
|
||||
}
|
||||
function appendThinking(text=''){
|
||||
$('emptyState').style.display='none';
|
||||
let row=$('thinkingRow');
|
||||
if(!row){
|
||||
row=document.createElement('div');
|
||||
row.className='msg-row';
|
||||
row.id='thinkingRow';
|
||||
$('msgInner').appendChild(row);
|
||||
}
|
||||
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
|
||||
row.innerHTML=_thinkingMarkup(text);
|
||||
scrollToBottom();
|
||||
}
|
||||
function updateThinking(text=''){appendThinking(text);}
|
||||
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
|
||||
|
||||
function fileIcon(name, type){
|
||||
|
||||
Reference in New Issue
Block a user