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
|
# 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
|
## [v0.50.77] — 2026-04-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ across 53 test files.
|
|||||||
|
|
||||||
### Slash commands
|
### Slash commands
|
||||||
- Type `/` in the composer for autocomplete dropdown
|
- 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
|
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||||
- Unrecognized commands pass through to the agent
|
- Unrecognized commands pass through to the agent
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class Session:
|
|||||||
pending_user_message: str=None,
|
pending_user_message: str=None,
|
||||||
pending_attachments=None,
|
pending_attachments=None,
|
||||||
pending_started_at=None,
|
pending_started_at=None,
|
||||||
|
compression_anchor_visible_idx=None,
|
||||||
|
compression_anchor_message_key=None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -69,6 +71,8 @@ class Session:
|
|||||||
self.pending_user_message = pending_user_message
|
self.pending_user_message = pending_user_message
|
||||||
self.pending_attachments = pending_attachments or []
|
self.pending_attachments = pending_attachments or []
|
||||||
self.pending_started_at = pending_started_at
|
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
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
@@ -110,6 +114,8 @@ class Session:
|
|||||||
'output_tokens': self.output_tokens,
|
'output_tokens': self.output_tokens,
|
||||||
'estimated_cost': self.estimated_cost,
|
'estimated_cost': self.estimated_cost,
|
||||||
'personality': self.personality,
|
'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):
|
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}}
|
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":
|
if parsed.path == "/api/chat/start":
|
||||||
return _handle_chat_start(handler, body)
|
return _handle_chat_start(handler, body)
|
||||||
|
|
||||||
@@ -1321,7 +1324,6 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
|
|
||||||
return False # 404
|
return False # 404
|
||||||
|
|
||||||
|
|
||||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# MIME types for static file serving. Hoisted to module scope to avoid
|
# 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})
|
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):
|
def _handle_skill_save(handler, body):
|
||||||
try:
|
try:
|
||||||
require(body, "name", "content")
|
require(body, "name", "content")
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
const COMMANDS=[
|
const COMMANDS=[
|
||||||
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
|
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
|
||||||
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
|
{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:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'},
|
||||||
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
|
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
|
||||||
{name:'new', desc:t('cmd_new'), fn:cmdNew},
|
{name:'new', desc:t('cmd_new'), fn:cmdNew},
|
||||||
@@ -37,11 +38,26 @@ function getMatchingCommands(prefix){
|
|||||||
return COMMANDS.filter(c=>c.name.startsWith(q));
|
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 ────────────────────────────────────────────────────────
|
// ── Command handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function cmdHelp(){
|
function cmdHelp(){
|
||||||
const lines=COMMANDS.map(c=>{
|
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}`;
|
return ` /${c.name}${usage} — ${c.desc}`;
|
||||||
});
|
});
|
||||||
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
|
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
|
||||||
@@ -54,6 +70,7 @@ function cmdClear(){
|
|||||||
if(!S.session)return;
|
if(!S.session)return;
|
||||||
S.messages=[];S.toolCalls=[];
|
S.messages=[];S.toolCalls=[];
|
||||||
clearLiveToolCards();
|
clearLiveToolCards();
|
||||||
|
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
$('emptyState').style.display='';
|
$('emptyState').style.display='';
|
||||||
showToast(t('conversation_cleared'));
|
showToast(t('conversation_cleared'));
|
||||||
@@ -92,19 +109,134 @@ async function cmdWorkspace(args){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cmdNew(){
|
async function cmdNew(){
|
||||||
|
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||||
await newSession();
|
await newSession();
|
||||||
await renderSessionList();
|
await renderSessionList();
|
||||||
$('msg').focus();
|
$('msg').focus();
|
||||||
showToast(t('new_session'));
|
showToast(t('new_session'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdCompact(){
|
async function _runManualCompression(focusTopic){
|
||||||
// Send as a regular message to the agent -- the agent's run_conversation
|
if(!S.session){showToast(t('no_active_session'));return;}
|
||||||
// preflight will detect the high token count and trigger _compress_context.
|
let visibleCount=0;
|
||||||
// We send a user message so it appears in the conversation.
|
try{
|
||||||
$('msg').value='Please compress and summarize the conversation context to free up space.';
|
const sid=S.session.session_id;
|
||||||
send();
|
// Preflight: verify the viewed session still exists before compressing.
|
||||||
showToast(t('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(){
|
async function cmdUsage(){
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ const LOCALES = {
|
|||||||
// commands.js
|
// commands.js
|
||||||
cmd_help: 'List available commands',
|
cmd_help: 'List available commands',
|
||||||
cmd_clear: 'Clear conversation messages',
|
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_model: 'Switch model (e.g. /model gpt-4o)',
|
||||||
cmd_workspace: 'Switch workspace by name',
|
cmd_workspace: 'Switch workspace by name',
|
||||||
cmd_new: 'Start a new chat session',
|
cmd_new: 'Start a new chat session',
|
||||||
@@ -72,6 +73,9 @@ const LOCALES = {
|
|||||||
available_commands: 'Available commands:',
|
available_commands: 'Available commands:',
|
||||||
type_slash: 'Type / to see commands',
|
type_slash: 'Type / to see commands',
|
||||||
conversation_cleared: 'Conversation cleared',
|
conversation_cleared: 'Conversation cleared',
|
||||||
|
command_label: 'Command',
|
||||||
|
context_compaction_label: 'Context compaction',
|
||||||
|
reference_only_label: 'Reference only',
|
||||||
model_usage: 'Usage: /model <name>',
|
model_usage: 'Usage: /model <name>',
|
||||||
no_model_match: 'No model matching "',
|
no_model_match: 'No model matching "',
|
||||||
switched_to: 'Switched to ',
|
switched_to: 'Switched to ',
|
||||||
@@ -81,6 +85,10 @@ const LOCALES = {
|
|||||||
workspace_switch_failed: 'Workspace switch failed: ',
|
workspace_switch_failed: 'Workspace switch failed: ',
|
||||||
new_session: 'New session created',
|
new_session: 'New session created',
|
||||||
compressing: 'Requesting context compression...',
|
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_on: 'Token usage on',
|
||||||
token_usage_off: 'Token usage off',
|
token_usage_off: 'Token usage off',
|
||||||
theme_usage: 'Usage: /theme ',
|
theme_usage: 'Usage: /theme ',
|
||||||
@@ -476,7 +484,8 @@ const LOCALES = {
|
|||||||
// commands.js
|
// commands.js
|
||||||
cmd_help: 'Listar los comandos disponibles',
|
cmd_help: 'Listar los comandos disponibles',
|
||||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
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_model: 'Cambiar de modelo (p. ej. /model gpt-4o)',
|
||||||
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
|
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
|
||||||
cmd_new: 'Iniciar una nueva sesión de chat',
|
cmd_new: 'Iniciar una nueva sesión de chat',
|
||||||
@@ -487,6 +496,9 @@ const LOCALES = {
|
|||||||
available_commands: 'Comandos disponibles:',
|
available_commands: 'Comandos disponibles:',
|
||||||
type_slash: 'Escribe / para ver los comandos',
|
type_slash: 'Escribe / para ver los comandos',
|
||||||
conversation_cleared: 'Conversación borrada',
|
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>',
|
model_usage: 'Uso: /model <name>',
|
||||||
no_model_match: 'No hay ningún modelo que coincida con "',
|
no_model_match: 'No hay ningún modelo que coincida con "',
|
||||||
switched_to: 'Se cambió a ',
|
switched_to: 'Se cambió a ',
|
||||||
@@ -496,6 +508,10 @@ const LOCALES = {
|
|||||||
workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ',
|
workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ',
|
||||||
new_session: 'Nueva sesión creada',
|
new_session: 'Nueva sesión creada',
|
||||||
compressing: 'Solicitando compresión del contexto...',
|
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_on: 'Uso de tokens activado',
|
||||||
token_usage_off: 'Uso de tokens desactivado',
|
token_usage_off: 'Uso de tokens desactivado',
|
||||||
theme_usage: 'Uso: /theme ',
|
theme_usage: 'Uso: /theme ',
|
||||||
@@ -881,7 +897,8 @@ const LOCALES = {
|
|||||||
// commands.js
|
// commands.js
|
||||||
cmd_help: 'Verfügbare Befehle auflisten',
|
cmd_help: 'Verfügbare Befehle auflisten',
|
||||||
cmd_clear: 'Konversationsverlauf löschen',
|
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_model: 'Modell wechseln (z.B. /model gpt-4o)',
|
||||||
cmd_workspace: 'Workspace nach Namen wechseln',
|
cmd_workspace: 'Workspace nach Namen wechseln',
|
||||||
cmd_new: 'Neue Chat-Sitzung starten',
|
cmd_new: 'Neue Chat-Sitzung starten',
|
||||||
@@ -892,6 +909,9 @@ const LOCALES = {
|
|||||||
available_commands: 'Verfügbare Befehle:',
|
available_commands: 'Verfügbare Befehle:',
|
||||||
type_slash: 'Tippe / für Befehle',
|
type_slash: 'Tippe / für Befehle',
|
||||||
conversation_cleared: 'Konversation gelöscht',
|
conversation_cleared: 'Konversation gelöscht',
|
||||||
|
command_label: 'Befehl',
|
||||||
|
context_compaction_label: 'Kontextkomprimierung',
|
||||||
|
reference_only_label: 'Nur Referenz',
|
||||||
model_usage: 'Nutzung: /model <name>',
|
model_usage: 'Nutzung: /model <name>',
|
||||||
no_model_match: 'Kein Modell gefunden für "',
|
no_model_match: 'Kein Modell gefunden für "',
|
||||||
switched_to: 'Gewechselt zu ',
|
switched_to: 'Gewechselt zu ',
|
||||||
@@ -901,6 +921,10 @@ const LOCALES = {
|
|||||||
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
|
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
|
||||||
new_session: 'Neue Sitzung erstellt',
|
new_session: 'Neue Sitzung erstellt',
|
||||||
compressing: 'Kontext-Komprimierung wird angefordert...',
|
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_on: 'Token-Verbrauch an',
|
||||||
token_usage_off: 'Token-Verbrauch aus',
|
token_usage_off: 'Token-Verbrauch aus',
|
||||||
theme_usage: 'Nutzung: /theme ',
|
theme_usage: 'Nutzung: /theme ',
|
||||||
@@ -1094,7 +1118,8 @@ const LOCALES = {
|
|||||||
// commands.js
|
// commands.js
|
||||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||||
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
|
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_model: '\u5207\u6362\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09',
|
||||||
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
|
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
|
||||||
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
|
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
|
||||||
@@ -1105,6 +1130,9 @@ const LOCALES = {
|
|||||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||||
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
||||||
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
|
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>',
|
model_usage: '\u7528\u6cd5\uff1a/model <name>',
|
||||||
no_model_match: '\u6ca1\u6709\u5339\u914d\u201c',
|
no_model_match: '\u6ca1\u6709\u5339\u914d\u201c',
|
||||||
switched_to: '\u5df2\u5207\u6362\u5230 ',
|
switched_to: '\u5df2\u5207\u6362\u5230 ',
|
||||||
@@ -1114,6 +1142,10 @@ const LOCALES = {
|
|||||||
workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a',
|
workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a',
|
||||||
new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd',
|
new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd',
|
||||||
compressing: '\u6b63\u5728\u8bf7\u6c42\u538b\u7f29\u4e0a\u4e0b\u6587...',
|
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_on: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5f00\u542f',
|
||||||
token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed',
|
token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed',
|
||||||
theme_usage: '\u7528\u6cd5\uff1a/theme ',
|
theme_usage: '\u7528\u6cd5\uff1a/theme ',
|
||||||
@@ -1498,7 +1530,8 @@ const LOCALES = {
|
|||||||
// commands.js
|
// commands.js
|
||||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||||
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
|
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_model: '\u5207\u63db\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09',
|
||||||
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
|
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
|
||||||
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
|
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
|
||||||
@@ -1509,6 +1542,9 @@ const LOCALES = {
|
|||||||
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
|
||||||
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
type_slash: '\u8f38\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
|
||||||
conversation_cleared: '\u5c0d\u8a71\u5df2\u6e05\u7a7a',
|
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>',
|
model_usage: '\u7528\u6cd5\uff1a/model <name>',
|
||||||
no_model_match: '\u6c92\u6709\u5339\u914d\u201c',
|
no_model_match: '\u6c92\u6709\u5339\u914d\u201c',
|
||||||
switched_to: '\u5df2\u5207\u63db\u5230 ',
|
switched_to: '\u5df2\u5207\u63db\u5230 ',
|
||||||
@@ -1518,6 +1554,10 @@ const LOCALES = {
|
|||||||
workspace_switch_failed: '\u5de5\u4f5c\u5340\u5207\u63db\u5931\u6557\uff1a',
|
workspace_switch_failed: '\u5de5\u4f5c\u5340\u5207\u63db\u5931\u6557\uff1a',
|
||||||
new_session: '\u5df2\u65b0\u5efa\u6703\u8a71',
|
new_session: '\u5df2\u65b0\u5efa\u6703\u8a71',
|
||||||
compressing: '\u6b63\u5728\u8981\u6c42\u58d3\u7e2e\u4e0a\u4e0b\u6587...',
|
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_on: 'Token \u7528\u91cf\u986f\u793a\u5df2\u958b\u555f',
|
||||||
token_usage_off: 'Token \u7528\u91cf\u986f\u793a\u5df2\u95dc\u9589',
|
token_usage_off: 'Token \u7528\u91cf\u986f\u793a\u5df2\u95dc\u9589',
|
||||||
theme_usage: '\u7528\u6cd5\uff1a/theme ',
|
theme_usage: '\u7528\u6cd5\uff1a/theme ',
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="messages-inner" id="msgInner"></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 id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-banner" id="updateBanner">
|
<div class="update-banner" id="updateBanner">
|
||||||
@@ -591,7 +592,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.81</span>
|
<span class="settings-version-badge">v0.50.82</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<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>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
async function send(){
|
async function send(){
|
||||||
const text=$('msg').value.trim();
|
const text=$('msg').value.trim();
|
||||||
if(!text&&!S.pendingFiles.length)return;
|
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
|
// Don't send while an inline message edit is active
|
||||||
if(document.querySelector('.msg-edit-area'))return;
|
if(document.querySelector('.msg-edit-area'))return;
|
||||||
// If busy, queue the message instead of dropping it
|
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
|
||||||
if(S.busy){
|
// If busy or a manual compression is still running, queue the message instead
|
||||||
|
if(S.busy||compressionRunning){
|
||||||
if(text){
|
if(text){
|
||||||
if(!S.session){await newSession();await renderSessionList();}
|
if(!S.session){await newSession();await renderSessionList();}
|
||||||
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
|
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles]});
|
||||||
@@ -19,6 +16,10 @@ async function send(){
|
|||||||
}
|
}
|
||||||
return;
|
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();}
|
if(!S.session){await newSession();await renderSessionList();}
|
||||||
|
|
||||||
const activeSid=S.session.session_id;
|
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 ── */
|
/* ── Tool call cards ── */
|
||||||
/* Running indicator dot (pulsing) */
|
/* 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 */
|
/* 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{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;}
|
.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-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;}
|
.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 ── */
|
/* ── Scrollbar polish ── */
|
||||||
::-webkit-scrollbar{width:5px;height:5px;}
|
::-webkit-scrollbar{width:5px;height:5px;}
|
||||||
::-webkit-scrollbar-track{background:transparent;}
|
::-webkit-scrollbar-track{background:transparent;}
|
||||||
|
|||||||
253
static/ui.js
253
static/ui.js
@@ -23,6 +23,12 @@ function shiftQueuedSessionMessage(sid){
|
|||||||
function getQueuedSessionCount(sid){
|
function getQueuedSessionCount(sid){
|
||||||
return _getSessionQueue(sid,false).length;
|
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]));
|
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
|
||||||
@@ -1114,8 +1120,200 @@ function _assistantTurnBlocks(turn){
|
|||||||
function _thinkingCardHtml(text){
|
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>`;
|
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(){
|
function renderMessages(){
|
||||||
const inner=$('msgInner');
|
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=>{
|
const vis=S.messages.filter(m=>{
|
||||||
if(!m||!m.role||m.role==='tool')return false;
|
if(!m||!m.role||m.role==='tool')return false;
|
||||||
if(m.role==='assistant'){
|
if(m.role==='assistant'){
|
||||||
@@ -1127,6 +1325,12 @@ function renderMessages(){
|
|||||||
});
|
});
|
||||||
$('emptyState').style.display=vis.length?'none':'';
|
$('emptyState').style.display=vis.length?'none':'';
|
||||||
inner.innerHTML='';
|
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=[];
|
const visWithIdx=[];
|
||||||
let rawIdx=0;
|
let rawIdx=0;
|
||||||
for(const m of S.messages){
|
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});
|
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
|
||||||
rawIdx++;
|
rawIdx++;
|
||||||
}
|
}
|
||||||
|
const insertionAnchor=_compressionAnchorIndex(
|
||||||
|
visWithIdx,
|
||||||
|
compressionState ? compressionState.anchorMessageKey : sessionCompressionAnchorKey,
|
||||||
|
compressionState
|
||||||
|
? (typeof compressionState.anchorVisibleIdx==='number' ? compressionState.anchorVisibleIdx : compressionState.anchorRawIdx)
|
||||||
|
: sessionCompressionAnchor
|
||||||
|
);
|
||||||
let _prevSepKey=null;
|
let _prevSepKey=null;
|
||||||
let currentAssistantTurn=null;
|
let currentAssistantTurn=null;
|
||||||
const assistantSegments=new Map();
|
const assistantSegments=new Map();
|
||||||
|
const userRows=new Map();
|
||||||
for(let vi=0;vi<visWithIdx.length;vi++){
|
for(let vi=0;vi<visWithIdx.length;vi++){
|
||||||
const {m,rawIdx}=visWithIdx[vi];
|
const {m,rawIdx}=visWithIdx[vi];
|
||||||
const _tsSep=m._ts||m.timestamp;
|
const _tsSep=m._ts||m.timestamp;
|
||||||
@@ -1208,9 +1420,22 @@ function renderMessages(){
|
|||||||
row.dataset.rawText=String(content).trim();
|
row.dataset.rawText=String(content).trim();
|
||||||
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||||
inner.appendChild(row);
|
inner.appendChild(row);
|
||||||
|
userRows.set(rawIdx, row);
|
||||||
continue;
|
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){
|
if(!currentAssistantTurn){
|
||||||
currentAssistantTurn=_createAssistantTurn(tsTitle);
|
currentAssistantTurn=_createAssistantTurn(tsTitle);
|
||||||
inner.appendChild(currentAssistantTurn);
|
inner.appendChild(currentAssistantTurn);
|
||||||
@@ -1234,6 +1459,32 @@ function renderMessages(){
|
|||||||
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
|
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
|
||||||
assistantSegments.set(rawIdx, 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).
|
// Insert settled tool call cards (history view only).
|
||||||
// During live streaming, tool cards are rendered in #liveToolCards by the
|
// During live streaming, tool cards are rendered in #liveToolCards by the
|
||||||
// tool SSE handler and never mixed into the message list until done fires.
|
// 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(derived.length) S.toolCalls=derived;
|
||||||
}
|
}
|
||||||
if(!S.busy && S.toolCalls && S.toolCalls.length){
|
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 = {};
|
const byAssistant = {};
|
||||||
for(const tc of S.toolCalls){
|
for(const tc of S.toolCalls){
|
||||||
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
|
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