From b49de92893af93226e04039e460c1b300492ebe9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 17 Apr 2026 23:55:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(/compress):=20manual=20session=20compressi?= =?UTF-8?q?on=20with=20focus=20topic=20=E2=80=94=20closes=20#469=20(PR=20#?= =?UTF-8?q?619=20by=20@franksong2702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 5 + README.md | 2 +- api/models.py | 6 + api/routes.py | 262 ++++++++++++++++++++++++++++++- static/commands.js | 150 ++++++++++++++++-- static/i18n.js | 50 +++++- static/index.html | 3 +- static/messages.js | 13 +- static/style.css | 345 ++++++++++++++++++++++++++++++++++++++++- static/ui.js | 253 +++++++++++++++++++++++++++++- tests/test_sprint46.py | 157 +++++++++++++++++++ 11 files changed, 1221 insertions(+), 25 deletions(-) create mode 100644 tests/test_sprint46.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f907ce8..bc41f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f3c2389..6d2be90 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ across 53 test files. ### Slash commands - Type `/` in the composer for autocomplete dropdown -- Built-in: `/help`, `/clear`, `/model `, `/workspace `, `/new`, `/usage`, `/theme`, `/compact` +- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model `, `/workspace `, `/new`, `/usage`, `/theme` - Arrow keys navigate, Tab/Enter select, Escape closes - Unrecognized commands pass through to the agent diff --git a/api/models.py b/api/models.py index be5dde2..22144d6 100644 --- a/api/models.py +++ b/api/models.py @@ -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): diff --git a/api/routes.py b/api/routes.py index da3933a..9e154bb 100644 --- a/api/routes.py +++ b/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") diff --git a/static/commands.js b/static/commands.js index 0a59832..bbf8e25 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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(){ diff --git a/static/i18n.js b/static/i18n.js index c530090..1494612 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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 ', 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 ', 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 ', 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 ', 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 ', 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 ', diff --git a/static/index.html b/static/index.html index 0506d63..8a47df3 100644 --- a/static/index.html +++ b/static/index.html @@ -207,6 +207,7 @@
+
@@ -591,7 +592,7 @@
System
- v0.50.81 + v0.50.82
diff --git a/static/messages.js b/static/messages.js index b96ba5e..fb5d4f6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/static/style.css b/static/style.css index 0339109..983fdb8 100644 --- a/static/style.css +++ b/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;} diff --git a/static/ui.js b/static/ui.js index 391c43d..39204ca 100644 --- a/static/ui.js +++ b/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 `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(text)}
`; } +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) + : ``; + 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 ` +
+
+
+ ${li('settings',13)} + ${esc(t('command_label'))} + ${esc(cmdText)} +
+
+
+
+ ${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', + }) + } +
+ ${referenceHtml}`; +} +function _compressionCardsNode(state){ + const wrap=document.createElement('div'); + wrap.className='compression-turn'; + wrap.innerHTML=`
${_compressionCardsHtml(state)}
`; + 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 ` +
+
+
+ ${li('star',13)} + ${esc(t('context_compaction_label'))} + ${esc(t('reference_only_label'))} · ${esc(preview)} + ${li('chevron-right',12)} + +
+
+
+
${esc(text)}
+
+
+
+ +
`; +} +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 ? `
${esc(statusDetail)}
` : ''; + const toggleHtml = hasBody ? `${li('chevron-right',12)}` : ''; + return ` +
+
+ ${statusIcon} + ${esc(statusLabel)} + ${esc(previewText)} + ${toggleHtml} +
+ ${bodyHtml} +
`; +} +function _contextCompactionMessageHtml(m, tsTitle=''){ + const text=msgContent(m)||String(m.content||''); + return `
${_compressionReferenceCardHtml(text, false, tsTitle)}
`; +} +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${bodyHtml}
${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; diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py new file mode 100644 index 0000000..3efba4f --- /dev/null +++ b/tests/test_sprint46.py @@ -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