feat(/compress): manual session compression with focus topic — closes #469 (PR #619 by @franksong2702)
POST /api/session/compress with optional focus_topic. Transcript-inline cards: command, running, complete (collapsible green), reference. /compact alias kept. Fixes: var(--green) undefined color, focus_topic 500-char cap. Independent review by @nesquena (4 passes).
This commit is contained in:
@@ -5,7 +5,8 @@
|
||||
const COMMANDS=[
|
||||
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
|
||||
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
|
||||
{name:'compact', desc:t('cmd_compact'), fn:cmdCompact},
|
||||
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]'},
|
||||
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact},
|
||||
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'},
|
||||
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
|
||||
{name:'new', desc:t('cmd_new'), fn:cmdNew},
|
||||
@@ -37,11 +38,26 @@ function getMatchingCommands(prefix){
|
||||
return COMMANDS.filter(c=>c.name.startsWith(q));
|
||||
}
|
||||
|
||||
function _compressionAnchorMessageKey(m){
|
||||
if(!m||!m.role||m.role==='tool') return null;
|
||||
let content='';
|
||||
try{
|
||||
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
|
||||
}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};
|
||||
}
|
||||
|
||||
// ── Command handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function cmdHelp(){
|
||||
const lines=COMMANDS.map(c=>{
|
||||
const usage=c.arg?` <${c.arg}>`:'';
|
||||
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
|
||||
return ` /${c.name}${usage} — ${c.desc}`;
|
||||
});
|
||||
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
|
||||
@@ -54,6 +70,7 @@ function cmdClear(){
|
||||
if(!S.session)return;
|
||||
S.messages=[];S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
renderMessages();
|
||||
$('emptyState').style.display='';
|
||||
showToast(t('conversation_cleared'));
|
||||
@@ -92,19 +109,134 @@ async function cmdWorkspace(args){
|
||||
}
|
||||
|
||||
async function cmdNew(){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
await newSession();
|
||||
await renderSessionList();
|
||||
$('msg').focus();
|
||||
showToast(t('new_session'));
|
||||
}
|
||||
|
||||
function cmdCompact(){
|
||||
// Send as a regular message to the agent -- the agent's run_conversation
|
||||
// preflight will detect the high token count and trigger _compress_context.
|
||||
// We send a user message so it appears in the conversation.
|
||||
$('msg').value='Please compress and summarize the conversation context to free up space.';
|
||||
send();
|
||||
showToast(t('compressing'));
|
||||
async function _runManualCompression(focusTopic){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
let visibleCount=0;
|
||||
try{
|
||||
const sid=S.session.session_id;
|
||||
// Preflight: verify the viewed session still exists before compressing.
|
||||
// This avoids a confusing "not found" toast when the UI is stale.
|
||||
try{
|
||||
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
if(!live||!live.session||live.session.session_id!==sid){
|
||||
throw new Error('session no longer available');
|
||||
}
|
||||
S.session=live.session;
|
||||
S.messages=live.session.messages||[];
|
||||
S.toolCalls=live.session.tool_calls||[];
|
||||
}catch(preflightErr){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(true);
|
||||
const body={session_id:sid};
|
||||
if(focusTopic) body.focus_topic=focusTopic;
|
||||
const visibleMessages=(S.messages||[]).filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool') 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|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
|
||||
}
|
||||
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
|
||||
});
|
||||
visibleCount=visibleMessages.length;
|
||||
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
|
||||
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
|
||||
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
|
||||
if(typeof setCompressionUi==='function'){
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'running',
|
||||
focusTopic:focusTopic||'',
|
||||
commandText,
|
||||
beforeCount:visibleCount,
|
||||
anchorVisibleIdx,
|
||||
anchorMessageKey,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
|
||||
renderMessages();
|
||||
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
|
||||
if(data&&data.session){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
if(data.session.session_id&&data.session.session_id!==currentSid){
|
||||
await loadSession(data.session.session_id);
|
||||
}else{
|
||||
S.session=data.session;
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=data.session.tool_calls||[];
|
||||
clearLiveToolCards();
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
updateQueueBadge(S.session.session_id);
|
||||
}
|
||||
}
|
||||
const summary=data&&data.summary;
|
||||
if(typeof setCompressionUi==='function'&&S.session){
|
||||
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
|
||||
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
|
||||
const referenceText=summaryRef || (referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'');
|
||||
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
|
||||
setCompressionUi({
|
||||
sessionId:S.session.session_id,
|
||||
phase:'done',
|
||||
focusTopic:effectiveFocus,
|
||||
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
|
||||
beforeCount:visibleCount,
|
||||
summary:summary||null,
|
||||
referenceText,
|
||||
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
|
||||
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
|
||||
});
|
||||
}
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
}catch(e){
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const currentSid=S.session&&S.session.session_id;
|
||||
setCompressionUi({
|
||||
sessionId:currentSid||'',
|
||||
phase:'error',
|
||||
focusTopic:(focusTopic||'').trim(),
|
||||
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
|
||||
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
|
||||
errorText:`Compression failed: ${e.message}`,
|
||||
anchorVisibleIdx: Math.max(0, visibleCount - 1),
|
||||
anchorMessageKey:null,
|
||||
});
|
||||
}
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
renderMessages();
|
||||
showToast('Compression failed: '+e.message);
|
||||
return;
|
||||
}
|
||||
if(typeof setBusy==='function') setBusy(false);
|
||||
}
|
||||
|
||||
async function cmdCompress(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdCompact(args){
|
||||
await _runManualCompression((args||'').trim());
|
||||
}
|
||||
|
||||
async function cmdUsage(){
|
||||
|
||||
Reference in New Issue
Block a user