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:
nesquena-hermes
2026-04-17 23:55:04 -07:00
committed by GitHub
parent b1aa1cfa4d
commit b49de92893
11 changed files with 1221 additions and 25 deletions

View File

@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog
## [v0.50.82] — 2026-04-18
### Added
- **`/compress` command with optional focus topic** — manual session compression runs as a real API call via `POST /api/session/compress`, replacing the old agent-message-based `/compact`. Accepts an optional focus topic (`/compress summarize code changes`) that guides what the compression preserves. The compression flow is shown as three transcript-inline cards: a command card (gold), a running card (blue with animated dots), and a collapsible green success card showing the message-count delta and token savings. A reference card renders the full context compaction summary. `/compact` continues to work as an alias. `focus_topic` capped at 500 chars for defense-in-depth. Fallback token estimation uses word-count approximation when model metadata helpers are unavailable — intentional for resilience. (Closes #469, PR #619 by @franksong2702)
## [v0.50.77] — 2026-04-17
### Changed

View File

@@ -436,7 +436,7 @@ across 53 test files.
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent

View File

@@ -48,6 +48,8 @@ class Session:
pending_user_message: str=None,
pending_attachments=None,
pending_started_at=None,
compression_anchor_visible_idx=None,
compression_anchor_message_key=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -69,6 +71,8 @@ class Session:
self.pending_user_message = pending_user_message
self.pending_attachments = pending_attachments or []
self.pending_started_at = pending_started_at
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
@property
def path(self):
@@ -110,6 +114,8 @@ class Session:
'output_tokens': self.output_tokens,
'estimated_cost': self.estimated_cost,
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,
}
def get_session(sid):

View File

@@ -913,6 +913,9 @@ def handle_post(handler, parsed) -> bool:
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
)
if parsed.path == "/api/session/compress":
return _handle_session_compress(handler, body)
if parsed.path == "/api/chat/start":
return _handle_chat_start(handler, body)
@@ -1321,7 +1324,6 @@ def handle_post(handler, parsed) -> bool:
return False # 404
# ── GET route helpers ─────────────────────────────────────────────────────────
# MIME types for static file serving. Hoisted to module scope to avoid
@@ -2481,6 +2483,264 @@ def _handle_clarify_respond(handler, body):
return j(handler, {"ok": True, "response": response})
def _handle_session_compress(handler, body):
def _visible_messages_for_anchor(messages):
out = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = m.get("role")
if not role or role == "tool":
continue
content = m.get("content", "")
has_attachments = bool(m.get("attachments"))
if role == "assistant":
tool_calls = m.get("tool_calls")
has_tool_calls = isinstance(tool_calls, list) and len(tool_calls) > 0
has_tool_use = False
has_reasoning = bool(m.get("reasoning"))
if isinstance(content, list):
for p in content:
if not isinstance(p, dict):
continue
if p.get("type") == "tool_use":
has_tool_use = True
if p.get("type") in {"thinking", "reasoning"}:
has_reasoning = True
text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
).strip()
else:
text = str(content or "").strip()
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(m)
continue
if isinstance(content, list):
text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
).strip()
else:
text = str(content or "").strip()
if text or has_attachments:
out.append(m)
return out
def _anchor_message_key(m):
if not isinstance(m, dict):
return None
role = str(m.get("role") or "")
if not role or role == "tool":
return None
content = m.get("content", "")
if isinstance(content, list):
text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
)
else:
text = str(content or "")
norm = " ".join(text.split()).strip()[:160]
ts = m.get("_ts") or m.get("timestamp")
attachments = m.get("attachments")
attach_count = len(attachments) if isinstance(attachments, list) else 0
if not norm and not attach_count and not ts:
return None
return {"role": role, "ts": ts, "text": norm, "attachments": attach_count}
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
sid = str(body.get("session_id") or "").strip()
if not sid:
return bad(handler, "session_id is required")
# Cap focus_topic to 500 chars — matches the defensive input-size pattern
# used elsewhere (session title :80, first-exchange snippets :500) and
# prevents a user from forwarding an unbounded string into the compressor
# prompt path. No privilege boundary here (user prompting themself), just
# cheap bound-checking.
focus_topic = str(body.get("focus_topic") or body.get("topic") or "").strip()[:500] or None
try:
s = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
if getattr(s, "active_stream_id", None):
return bad(handler, "Session is still streaming; wait for the current turn to finish.", 409)
try:
from api.streaming import _sanitize_messages_for_api
messages = _sanitize_messages_for_api(s.messages)
if len(messages) < 4:
return bad(handler, "Not enough conversation to compress (need at least 4 messages).")
def _fallback_estimate_messages_tokens_rough(msgs):
"""Fallback heuristic token estimate when runtime metadata helpers are absent.
Uses whitespace token-like word counting only. This intentionally
over/under-estimates BPE token counts (roughly around x3/x4 scale),
and is only for resilient fallback behavior.
"""
total = 0
for m in msgs or []:
if not isinstance(m, dict):
continue
content = m.get("content", "")
if isinstance(content, list):
content_text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict)
)
else:
content_text = str(content or "")
total += len(content_text.split())
return max(1, total)
def _fallback_summarize_manual_compression(original_messages, compressed_messages, before_tokens, after_tokens, focus_topic=None):
"""Lightweight fallback summary to keep /session/compress usable in tests/runtime."""
after_tokens = after_tokens if after_tokens is not None else _fallback_estimate_messages_tokens_rough(compressed_messages)
headline = f"Compressed: {len(original_messages)} \u2192 {len(compressed_messages)} messages"
summary = {
"headline": headline,
"token_line": f"Rough transcript estimate: ~{before_tokens} \u2192 ~{after_tokens} tokens",
"note": f"Focus: {focus_topic}" if focus_topic else None,
}
summary["reference_message"] = (
f"[CONTEXT COMPACTION \u2014 REFERENCE ONLY] {headline}\n"
f"{summary['token_line']}\n"
+ (summary["note"] + "\n" if summary.get("note") else "")
+ "Compression completed."
)
return summary
def _estimate_messages_tokens_rough(msgs):
try:
from agent.model_metadata import estimate_messages_tokens_rough
return estimate_messages_tokens_rough(msgs)
except Exception:
return _fallback_estimate_messages_tokens_rough(msgs)
def _summarize_manual_compression(
original_messages,
compressed_messages,
before_tokens,
after_tokens,
focus_topic=None,
):
try:
from agent.manual_compression_feedback import summarize_manual_compression
return summarize_manual_compression(
original_messages,
compressed_messages,
before_tokens,
after_tokens,
)
except Exception:
return _fallback_summarize_manual_compression(
original_messages,
compressed_messages,
before_tokens,
after_tokens,
focus_topic,
)
import api.config as _cfg
import hermes_cli.runtime_provider as _runtime_provider
import run_agent as _run_agent
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(s.model)
resolved_api_key = None
try:
_rt = _runtime_provider.resolve_runtime_provider(requested=resolved_provider)
resolved_api_key = _rt.get("api_key")
if not resolved_provider:
resolved_provider = _rt.get("provider")
if not resolved_base_url:
resolved_base_url = _rt.get("base_url")
except Exception as _e:
logger.warning("resolve_runtime_provider failed for compression: %s", _e)
if not resolved_api_key:
return bad(handler, "No provider configured -- cannot compress.")
with _cfg._get_session_agent_lock(sid):
original_messages = list(messages)
approx_tokens = _estimate_messages_tokens_rough(original_messages)
agent = _run_agent.AIAgent(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
api_key=resolved_api_key,
platform="cli",
quiet_mode=True,
enabled_toolsets=_resolve_cli_toolsets(),
session_id=sid,
)
compressed = agent.context_compressor.compress(
original_messages,
current_tokens=approx_tokens,
focus_topic=focus_topic,
)
new_tokens = _estimate_messages_tokens_rough(compressed)
summary = _summarize_manual_compression(
original_messages,
compressed,
approx_tokens,
new_tokens,
focus_topic=focus_topic,
)
s.messages = compressed
s.tool_calls = []
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = []
s.pending_started_at = None
visible_after = _visible_messages_for_anchor(compressed)
s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None
s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None
s.save()
session_payload = redact_session_data(
s.compact() | {
"messages": s.messages,
"tool_calls": s.tool_calls,
"active_stream_id": s.active_stream_id,
"pending_user_message": s.pending_user_message,
"pending_attachments": s.pending_attachments,
"pending_started_at": s.pending_started_at,
"compression_anchor_visible_idx": getattr(s, "compression_anchor_visible_idx", None),
"compression_anchor_message_key": getattr(s, "compression_anchor_message_key", None),
}
)
return j(
handler,
{
"ok": True,
"session": session_payload,
"summary": summary,
"focus_topic": focus_topic,
},
)
except Exception as e:
logger.warning("Manual session compression failed: %s", e)
return bad(handler, f"Compression failed: {_sanitize_error(e)}")
def _handle_skill_save(handler, body):
try:
require(body, "name", "content")

View File

@@ -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(){

View File

@@ -61,7 +61,8 @@ const LOCALES = {
// commands.js
cmd_help: 'List available commands',
cmd_clear: 'Clear conversation messages',
cmd_compact: 'Compress conversation context',
cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])',
cmd_compact_alias: 'Legacy alias for /compress',
cmd_model: 'Switch model (e.g. /model gpt-4o)',
cmd_workspace: 'Switch workspace by name',
cmd_new: 'Start a new chat session',
@@ -72,6 +73,9 @@ const LOCALES = {
available_commands: 'Available commands:',
type_slash: 'Type / to see commands',
conversation_cleared: 'Conversation cleared',
command_label: 'Command',
context_compaction_label: 'Context compaction',
reference_only_label: 'Reference only',
model_usage: 'Usage: /model <name>',
no_model_match: 'No model matching "',
switched_to: 'Switched to ',
@@ -81,6 +85,10 @@ const LOCALES = {
workspace_switch_failed: 'Workspace switch failed: ',
new_session: 'New session created',
compressing: 'Requesting context compression...',
compress_running_label: 'Compressing',
compress_complete_label: 'Compression complete',
compress_failed_label: 'Compression failed',
focus_label: 'Focus',
token_usage_on: 'Token usage on',
token_usage_off: 'Token usage off',
theme_usage: 'Usage: /theme ',
@@ -476,7 +484,8 @@ const LOCALES = {
// commands.js
cmd_help: 'Listar los comandos disponibles',
cmd_clear: 'Borrar los mensajes de la conversación',
cmd_compact: 'Comprimir el contexto de la conversación',
cmd_compress: 'Comprimir manualmente el contexto de la conversación (uso: /compress [tema])',
cmd_compact_alias: 'Alias antiguo de /compress',
cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)',
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
cmd_new: 'Iniciar una nueva sesión de chat',
@@ -487,6 +496,9 @@ const LOCALES = {
available_commands: 'Comandos disponibles:',
type_slash: 'Escribe / para ver los comandos',
conversation_cleared: 'Conversación borrada',
command_label: 'Comando',
context_compaction_label: 'Compacción de contexto',
reference_only_label: 'Solo referencia',
model_usage: 'Uso: /model <name>',
no_model_match: 'No hay ningún modelo que coincida con "',
switched_to: 'Se cambió a ',
@@ -496,6 +508,10 @@ const LOCALES = {
workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ',
new_session: 'Nueva sesión creada',
compressing: 'Solicitando compresión del contexto...',
compress_running_label: 'Comprimiendo',
compress_complete_label: 'Compresión completa',
compress_failed_label: 'La compresión falló',
focus_label: 'Tema',
token_usage_on: 'Uso de tokens activado',
token_usage_off: 'Uso de tokens desactivado',
theme_usage: 'Uso: /theme ',
@@ -881,7 +897,8 @@ const LOCALES = {
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
cmd_clear: 'Konversationsverlauf löschen',
cmd_compact: 'Kontext komprimieren',
cmd_compress: 'Kontext manuell komprimieren (Nutzung: /compress [Thema])',
cmd_compact_alias: 'Alte Alias für /compress',
cmd_model: 'Modell wechseln (z.B. /model gpt-4o)',
cmd_workspace: 'Workspace nach Namen wechseln',
cmd_new: 'Neue Chat-Sitzung starten',
@@ -892,6 +909,9 @@ const LOCALES = {
available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht',
command_label: 'Befehl',
context_compaction_label: 'Kontextkomprimierung',
reference_only_label: 'Nur Referenz',
model_usage: 'Nutzung: /model <name>',
no_model_match: 'Kein Modell gefunden für "',
switched_to: 'Gewechselt zu ',
@@ -901,6 +921,10 @@ const LOCALES = {
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
new_session: 'Neue Sitzung erstellt',
compressing: 'Kontext-Komprimierung wird angefordert...',
compress_running_label: 'Komprimierung',
compress_complete_label: 'Komprimierung abgeschlossen',
compress_failed_label: 'Komprimierung fehlgeschlagen',
focus_label: 'Thema',
token_usage_on: 'Token-Verbrauch an',
token_usage_off: 'Token-Verbrauch aus',
theme_usage: 'Nutzung: /theme ',
@@ -1094,7 +1118,8 @@ const LOCALES = {
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
cmd_compact: '\u538b\u7f29\u5bf9\u8bdd\u4e0a\u4e0b\u6587',
cmd_compress: '\u624b\u52a8\u538b\u7f29\u5bf9\u8bdd\u4e0a\u4e0b\u6587\uff08\u7528\u6cd5\uff1a/compress [\u4e3b\u9898]\uff09',
cmd_compact_alias: '\u65e7\u522b\u540d\uff1a/compress',
cmd_model: '\u5207\u6362\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09',
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
@@ -1105,6 +1130,9 @@ const LOCALES = {
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
command_label: '\u547d\u4ee4',
context_compaction_label: '\u4e0a\u4e0b\u6587\u538b\u7f29',
reference_only_label: '\u4ec5\u4f9b\u53c2\u8003',
model_usage: '\u7528\u6cd5\uff1a/model <name>',
no_model_match: '\u6ca1\u6709\u5339\u914d\u201c',
switched_to: '\u5df2\u5207\u6362\u5230 ',
@@ -1114,6 +1142,10 @@ const LOCALES = {
workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a',
new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd',
compressing: '\u6b63\u5728\u8bf7\u6c42\u538b\u7f29\u4e0a\u4e0b\u6587...',
compress_running_label: '\u538b\u7f29\u4e2d',
compress_complete_label: '\u538b\u7f29\u5b8c\u6210',
compress_failed_label: '\u538b\u7f29\u5931\u8d25',
focus_label: '\u4e3b\u9898',
token_usage_on: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5f00\u542f',
token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed',
theme_usage: '\u7528\u6cd5\uff1a/theme ',
@@ -1498,7 +1530,8 @@ const LOCALES = {
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
cmd_compact: '\u58d3\u7e2e\u5c0d\u8a71\u4e0a\u4e0b\u6587',
cmd_compress: '\u624b\u52d5\u58d3\u7e2e\u5c0d\u8a71\u4e0a\u4e0b\u6587\uff08\u7528\u6cd5\uff1a/compress [\u4e3b\u984c]\uff09',
cmd_compact_alias: '\u820a\u5225\u540d\uff1a/compress',
cmd_model: '\u5207\u63db\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09',
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
@@ -1509,6 +1542,9 @@ const LOCALES = {
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a',
command_label: '\u547d\u4ee4',
context_compaction_label: '\u4e0a\u4e0b\u6587\u58d3\u7e2e',
reference_only_label: '\u50c5\u4f9b\u53c3\u8003',
model_usage: '\u7528\u6cd5\uff1a/model <name>',
no_model_match: '\u6c92\u6709\u5339\u914d\u201c',
switched_to: '\u5df2\u5207\u63db\u5230 ',
@@ -1518,6 +1554,10 @@ const LOCALES = {
workspace_switch_failed: '\u5de5\u4f5c\u5340\u5207\u63db\u5931\u6557\uff1a',
new_session: '\u5df2\u65b0\u5efa\u6703\u8a71',
compressing: '\u6b63\u5728\u8981\u6c42\u58d3\u7e2e\u4e0a\u4e0b\u6587...',
compress_running_label: '\u58d3\u7e2e\u4e2d',
compress_complete_label: '\u58d3\u7e2e\u5b8c\u6210',
compress_failed_label: '\u58d3\u7e2e\u5931\u6557',
focus_label: '\u4e3b\u984c',
token_usage_on: 'Token \u7528\u91cf\u986f\u793a\u5df2\u958b\u555f',
token_usage_off: 'Token \u7528\u91cf\u986f\u793a\u5df2\u95dc\u9589',
theme_usage: '\u7528\u6cd5\uff1a/theme ',

View File

@@ -207,6 +207,7 @@
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
<div id="liveCompressionCards" class="live-compression-cards"></div>
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
</div>
<div class="update-banner" id="updateBanner">
@@ -591,7 +592,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.81</span>
<span class="settings-version-badge">v0.50.82</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -1,14 +1,11 @@
async function send(){
const text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length)return;
// Slash command intercept -- local commands handled without agent round-trip
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
$('msg').value='';autoResize();hideCmdDropdown();return;
}
// Don't send while an inline message edit is active
if(document.querySelector('.msg-edit-area'))return;
// If busy, queue the message instead of dropping it
if(S.busy){
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
// If busy or a manual compression is still running, queue the message instead
if(S.busy||compressionRunning){
if(text){
if(!S.session){await newSession();await renderSessionList();}
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
@@ -19,6 +16,10 @@ async function send(){
}
return;
}
// Slash command intercept -- local commands handled without agent round-trip
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
$('msg').value='';autoResize();hideCmdDropdown();return;
}
if(!S.session){await newSession();await renderSessionList();}
const activeSid=S.session.session_id;

View File

@@ -990,7 +990,17 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── Tool call cards ── */
/* Running indicator dot (pulsing) */
.tool-card-running-dot{width:7px;height:7px;border-radius:50%;background:var(--blue);opacity:.8;flex-shrink:0;animation:pulse 1.2s ease-in-out infinite;}
.tool-card-running-dot{
display:inline-block;
width:7px;
height:7px;
border-radius:50%;
background:var(--blue);
opacity:.8;
flex-shrink:0;
vertical-align:middle;
animation:pulse 1.2s ease-in-out infinite;
}
/* Show more button inside tool card result */
.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;}
.tool-card-more:hover{opacity:1;}
@@ -1032,6 +1042,339 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;}
/* ── Manual compression cards (transient transcript-local feedback) ── */
.live-compression-cards{
display:none;
max-width:800px;
width:100%;
margin:0 auto;
padding:0 24px;
}
.compression-block{
display:flex;
flex-direction:column;
width:100%;
}
.compression-turn{
width:100%;
}
.compression-turn-blocks{
display:flex;
flex-direction:column;
}
.compression-card-row{
margin:0;
padding:0;
}
.compression-card-row + .compression-card-row{
margin-top:4px;
}
.tool-card-compress-command{
background:rgba(201,168,76,.02);
border-color:rgba(201,168,76,.16);
}
.tool-card-compress-command .tool-card-name{
color:var(--gold);
font-weight:600;
}
.tool-card-compress-running{
background:rgba(124,185,255,.04);
border-color:rgba(124,185,255,.24);
}
.tool-card-compress-running .tool-card-name{
color:var(--blue);
}
.tool-card-compress-error{
background:rgba(248,113,113,.05);
border-color:rgba(248,113,113,.28);
}
.tool-card-compress-error .tool-card-name{
color:#fca5a5;
}
.tool-card-compress-complete{
background:rgba(78,201,132,.05);
border-color:rgba(78,201,132,.18);
}
.tool-card-compress-complete .tool-card-name{
color:#4ec984;
}
.tool-card-compress-reference{
background:rgba(124,185,255,.04);
border-color:rgba(124,185,255,.18);
}
.tool-card-compress-reference:hover{
border-color:rgba(124,185,255,.28);
}
.tool-card-compress-reference .tool-card-name{
color:var(--blue);
}
.compression-row{
margin:0 0 4px;
padding:0;
}
.compression-card{
margin-left:var(--msg-rail);
max-width:var(--msg-max);
border-radius:8px;
overflow:hidden;
background:rgba(255,255,255,.03);
border:1px solid rgba(255,255,255,.08);
}
.compression-card-header{
display:flex;
align-items:center;
gap:8px;
padding:5px 10px;
user-select:none;
}
.compression-card-icon{
font-size:13px;
flex-shrink:0;
opacity:.82;
}
.compression-card-bubbles{
display:inline-flex;
gap:3px;
align-items:center;
flex-shrink:0;
}
.compression-card-bubbles span{
width:5px;
height:5px;
border-radius:50%;
background:var(--blue);
opacity:.45;
animation:compressionPulse 1.05s ease-in-out infinite;
}
.compression-card-bubbles span:nth-child(2){animation-delay:.14s;}
.compression-card-bubbles span:nth-child(3){animation-delay:.28s;}
@keyframes compressionPulse{
0%,80%,100%{transform:translateY(0);opacity:.35;}
40%{transform:translateY(-1px);opacity:1;}
}
.compression-card-label{
font-size:11px;
font-weight:700;
font-family:'SF Mono',ui-monospace,monospace;
letter-spacing:.03em;
color:var(--muted);
flex-shrink:0;
}
.compression-card-command{
font-size:11px;
color:var(--text);
font-family:'SF Mono',ui-monospace,monospace;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
flex:1;
}
.compression-card-body{
padding:0 12px 8px 40px;
font-size:11px;
line-height:1.55;
color:var(--muted);
}
.compression-card-body .compression-note{
display:block;
margin-top:2px;
opacity:.82;
}
.compression-card-command-row{
border-color:rgba(201,168,76,.22);
background:rgba(201,168,76,.04);
}
.compression-card-command-row .compression-card-label{color:var(--gold);}
.compression-card-running-row{
border-color:rgba(124,185,255,.24);
background:rgba(124,185,255,.04);
}
.compression-card-running-row .compression-card-label{color:var(--blue);}
.compression-card-error-row{
border-color:rgba(248,113,113,.28);
background:rgba(248,113,113,.05);
}
.compression-card-error-row .compression-card-label{color:#fca5a5;}
.compression-card-focus{
margin-top:3px;
opacity:.85;
}
.compression-card-focus .compression-card-focus-label{
color:var(--muted);
font-family:'SF Mono',ui-monospace,monospace;
}
.compression-card-error-body{
color:#fecaca;
}
.compression-reference-row{
margin-top:0;
}
.compression-reference-card{
margin-left:var(--msg-rail);
max-width:var(--msg-max);
border-radius:8px;
overflow:hidden;
background:rgba(124,185,255,.04);
border:1px solid rgba(124,185,255,.18);
transition:border-color .15s, background .15s;
}
.compression-reference-card:hover{border-color:rgba(124,185,255,.28);}
.compression-reference-header{
display:flex;
align-items:center;
gap:8px;
padding:5px 10px;
cursor:pointer;
user-select:none;
}
.compression-reference-icon{
font-size:13px;
flex-shrink:0;
opacity:.82;
color:var(--blue);
}
.compression-reference-label,
.compression-reference-kind{
font-size:11px;
font-weight:700;
font-family:'SF Mono',ui-monospace,monospace;
letter-spacing:.03em;
flex-shrink:0;
white-space:nowrap;
}
.compression-reference-label{color:var(--blue);}
.compression-reference-kind{color:var(--gold);}
.compression-reference-preview{
font-size:11px;
color:var(--muted);
opacity:.72;
flex:1;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.compression-reference-toggle{
font-size:10px;
color:var(--muted);
opacity:.55;
flex-shrink:0;
display:inline-flex;
align-items:center;
justify-content:center;
transform-origin:center;
transition:transform .18s ease;
}
.compression-reference-card.open .compression-reference-toggle{
transform:rotate(90deg);
}
.compression-reference-copy{
flex-shrink:0;
opacity:.65;
}
.compression-reference-body{
display:block;
max-height:0;
opacity:0;
overflow:hidden;
padding:0 12px;
transition:max-height .22s ease, opacity .18s ease, padding .22s ease, border-top-color .22s ease;
border-top:1px solid transparent;
}
.compression-reference-card.open .compression-reference-body{
max-height:none;
overflow:visible;
opacity:1;
padding:8px 12px 10px;
border-top-color:rgba(124,185,255,.1);
}
.compression-reference-body pre{
margin:0;
font-family:'SF Mono',ui-monospace,monospace;
font-size:11px;
line-height:1.55;
white-space:pre-wrap;
word-break:break-word;
color:var(--muted);
}
.compression-complete-card{
background:rgba(78,201,132,.05);
border:1px solid rgba(78,201,132,.18);
border-radius:8px;
padding:0;
margin-left:var(--msg-rail);
max-width:var(--msg-max);
transition:border-color .15s, background .15s;
}
.compression-complete-card:hover{
border-color:rgba(78,201,132,.3);
background:rgba(78,201,132,.07);
}
.compression-complete-header{
display:flex;
align-items:center;
gap:8px;
padding:5px 10px;
cursor:pointer;
user-select:none;
color:#4ec984;
font-size:12px;
font-weight:600;
opacity:.88;
}
.compression-complete-header:hover{opacity:1;}
.compression-complete-icon{opacity:.78;flex-shrink:0;}
.compression-complete-label{
font-size:11px;
font-weight:700;
font-family:'SF Mono',ui-monospace,monospace;
letter-spacing:.03em;
flex-shrink:0;
}
.compression-complete-title{
flex:1;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:var(--text);
font-family:'SF Mono',ui-monospace,monospace;
font-size:11px;
}
.compression-complete-toggle{
margin-left:auto;
font-size:10px;
color:var(--muted);
opacity:.55;
display:inline-flex;
align-items:center;
justify-content:center;
transform-origin:center;
transition:transform .18s ease;
}
.compression-complete-card.open .compression-complete-toggle{
transform:rotate(90deg);
}
.compression-complete-body{
max-height:0;
opacity:0;
overflow:hidden;
padding:0 12px;
border-top:1px solid transparent;
transition:max-height .22s ease, opacity .18s ease, padding .22s ease, border-top-color .22s ease;
color:var(--muted);
font-size:11px;
line-height:1.55;
}
.compression-complete-card.open .compression-complete-body{
max-height:220px;
opacity:1;
padding:8px 12px;
border-top-color:rgba(78,201,132,.12);
}
/* ── Scrollbar polish ── */
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-track{background:transparent;}

View File

@@ -23,6 +23,12 @@ function shiftQueuedSessionMessage(sid){
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
@@ -1114,8 +1120,200 @@ function _assistantTurnBlocks(turn){
function _thinkingCardHtml(text){
return `<div class="thinking-card"><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(text)}</pre></div></div>`;
}
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;
_setCompressionSessionLock(null);
renderCompressionUi();
}
function setCompressionUi(state){
if(!state){
clearCompressionUi();
return;
}
window._compressionUi={...state};
if(state.sessionId) _setCompressionSessionLock(state.sessionId);
renderCompressionUi();
}
function _compressionCardsHtml(state){
if(!state) return '';
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 _compressionCardsNode(state){
const wrap=document.createElement('div');
wrap.className='compression-turn';
wrap.innerHTML=`<div class="compression-turn-blocks">${_compressionCardsHtml(state)}</div>`;
return wrap;
}
function _isContextCompactionMessage(m){
if(!m||m.role!=='assistant') return false;
const text=msgContent(m)||String(m.content||'');
return /^\s*\[context compaction/i.test(text) || /^\s*context compaction/i.test(text);
}
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;
if(
candidate.role===String(anchorKey.role||'') &&
String(candidate.ts??'')===String(anchorKey.ts??'') &&
String(candidate.text||'')===String(anchorKey.text||'') &&
Number(candidate.attachments||0)===Number(anchorKey.attachments||0)
){
return i;
}
}
}
return typeof fallbackIdx==='number' ? fallbackIdx : null;
}
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 _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 _contextCompactionMessageHtml(m, tsTitle=''){
const text=msgContent(m)||String(m.content||'');
return `<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(text, false, tsTitle)}</div></div>`;
}
function renderCompressionUi(){
const el=$('liveCompressionCards');
if(!el) return;
el.innerHTML='';
el.style.display='none';
}
function renderMessages(){
const inner=$('msgInner');
const compressionState=_compressionStateForCurrentSession();
if(window._compressionUi && !compressionState) clearCompressionUi();
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 vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
if(m.role==='assistant'){
@@ -1127,6 +1325,12 @@ function renderMessages(){
});
$('emptyState').style.display=vis.length?'none':'';
inner.innerHTML='';
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m));
const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):'';
const referenceNode=(!compressionState && referenceMessage && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey))
? (()=>{const row=document.createElement('div');row.innerHTML=_compressionReferenceCardHtml(referenceText,false);return row.firstElementChild;})()
: null;
const visWithIdx=[];
let rawIdx=0;
for(const m of S.messages){
@@ -1136,9 +1340,17 @@ function renderMessages(){
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
rawIdx++;
}
const insertionAnchor=_compressionAnchorIndex(
visWithIdx,
compressionState ? compressionState.anchorMessageKey : sessionCompressionAnchorKey,
compressionState
? (typeof compressionState.anchorVisibleIdx==='number' ? compressionState.anchorVisibleIdx : compressionState.anchorRawIdx)
: sessionCompressionAnchor
);
let _prevSepKey=null;
let currentAssistantTurn=null;
const assistantSegments=new Map();
const userRows=new Map();
for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi];
const _tsSep=m._ts||m.timestamp;
@@ -1208,9 +1420,22 @@ function renderMessages(){
row.dataset.rawText=String(content).trim();
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
inner.appendChild(row);
userRows.set(rawIdx, row);
continue;
}
if(_isContextCompactionMessage(m)){
if(compressionState || referenceNode){
continue;
}else{
currentAssistantTurn=null;
const row=document.createElement('div');
row.innerHTML=_contextCompactionMessageHtml(m, tsTitle);
inner.appendChild(row.firstElementChild);
continue;
}
}
if(!currentAssistantTurn){
currentAssistantTurn=_createAssistantTurn(tsTitle);
inner.appendChild(currentAssistantTurn);
@@ -1234,6 +1459,32 @@ function renderMessages(){
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
assistantSegments.set(rawIdx, seg);
}
function _insertCompressionLikeNode(node){
if(!node) return;
if(insertionAnchor!==null && visWithIdx[insertionAnchor]){
const anchorRawIdx=visWithIdx[insertionAnchor].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);
}
_insertCompressionLikeNode(compressionNode);
_insertCompressionLikeNode(referenceNode);
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.
@@ -1309,7 +1560,7 @@ function renderMessages(){
if(derived.length) S.toolCalls=derived;
}
if(!S.busy && S.toolCalls && S.toolCalls.length){
inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove());
inner.querySelectorAll('.tool-card-row:not([data-compression-card])').forEach(el=>el.remove());
const byAssistant = {};
for(const tc of S.toolCalls){
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;

157
tests/test_sprint46.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Sprint 46 Tests: manual session compression with optional focus topic.
"""
import contextlib
import io
import json
import sys
import types
from api.models import Session
from api.config import SESSION_DIR
from api.routes import _handle_session_compress
from tests._pytest_port import BASE
class _FakeHandler:
def __init__(self):
self.wfile = io.BytesIO()
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
class _FakeCompressor:
def __init__(self):
self.calls = []
def compress(self, messages, current_tokens=None, focus_topic=None):
self.calls.append(
{
"messages": list(messages),
"current_tokens": current_tokens,
"focus_topic": focus_topic,
}
)
if len(messages) >= 2:
return [messages[0], messages[-1]]
return list(messages)
class _FakeAgent:
last_instance = None
def __init__(self, **kwargs):
self.kwargs = kwargs
self.context_compressor = _FakeCompressor()
_FakeAgent.last_instance = self
def _make_session(messages=None):
SESSION_DIR.mkdir(parents=True, exist_ok=True)
messages = messages or [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
s = Session(
session_id="compress_test_001",
title="Untitled",
workspace="/tmp/hermes-webui-test",
model="openai/gpt-5.4-mini",
messages=messages,
)
s.save(touch_updated_at=False)
return s.session_id
def test_session_compress_requires_session_id(cleanup_test_sessions):
handler = _FakeHandler()
_handle_session_compress(handler, {})
assert handler.status == 400
assert handler.payload()["error"] == "Missing required field(s): session_id"
def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
created = cleanup_test_sessions
sid = _make_session()
created.append(sid)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _FakeAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
import api.config as _cfg
fake_runtime_provider = types.ModuleType("hermes_cli.runtime_provider")
fake_runtime_provider.resolve_runtime_provider = lambda requested=None: {
"api_key": "fake-key",
"provider": requested or "openai",
"base_url": "https://api.openai.com/v1",
}
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.__path__ = []
fake_hermes_cli.runtime_provider = fake_runtime_provider
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_provider)
import hermes_cli.runtime_provider as _rtp
monkeypatch.setattr(
_cfg,
"resolve_model_provider",
lambda model: ("openai/gpt-5.4-mini", "openai", "https://api.openai.com/v1"),
)
monkeypatch.setattr(
_cfg,
"_get_session_agent_lock",
lambda sid: contextlib.nullcontext(),
)
monkeypatch.setattr(
_rtp,
"resolve_runtime_provider",
lambda requested=None: {
"api_key": "fake-key",
"provider": requested or "openai",
"base_url": "https://api.openai.com/v1",
},
)
handler = _FakeHandler()
_handle_session_compress(handler, {"session_id": sid, "focus_topic": "database schema"})
assert handler.status == 200
payload = handler.payload()
assert payload["ok"] is True
assert payload["focus_topic"] == "database schema"
assert payload["summary"]["headline"] == "Compressed: 4 → 2 messages"
assert payload["session"]["session_id"] == sid
assert payload["session"]["messages"] == [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "four"},
]
assert _FakeAgent.last_instance is not None
assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema"
def test_static_commands_js_registers_compress_alias(cleanup_test_sessions):
from pathlib import Path
with open(Path(__file__).resolve().parents[1] / "static" / "commands.js", encoding="utf-8") as f:
src = f.read()
assert "name:'compress'" in src
assert "name:'compact'" in src
assert "/api/session/compress" in src
assert "cmdCompress" in src
assert "cmdCompact" in src