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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
262
api/routes.py
262
api/routes.py
@@ -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")
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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 ',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
345
static/style.css
345
static/style.css
@@ -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;}
|
||||
|
||||
253
static/ui.js
253
static/ui.js
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
157
tests/test_sprint46.py
Normal 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
|
||||
Reference in New Issue
Block a user