mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 387: PR #2600
This commit is contained in:
@@ -53,6 +53,24 @@ def _content_has_part_type(content, part_types):
|
||||
)
|
||||
|
||||
|
||||
def _is_context_compression_marker(message):
|
||||
"""Return true for synthetic compression/reference cards, not user turns."""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
role = message.get("role")
|
||||
if not role or role == "tool":
|
||||
return False
|
||||
text = _content_text(
|
||||
message.get("content", ""),
|
||||
part_types={"text", "input_text", "output_text"},
|
||||
).lower().lstrip()
|
||||
return (
|
||||
text.startswith("[context compaction")
|
||||
or text.startswith("context compaction")
|
||||
or text.startswith("[your active task list was preserved across context compression]")
|
||||
)
|
||||
|
||||
|
||||
def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
|
||||
"""Return transcript messages that can anchor compression UI metadata.
|
||||
|
||||
@@ -70,6 +88,8 @@ def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
|
||||
role = message.get("role")
|
||||
if not role or role == "tool":
|
||||
continue
|
||||
if _is_context_compression_marker(message):
|
||||
continue
|
||||
|
||||
content = message.get("content", "")
|
||||
has_attachments = bool(message.get("attachments"))
|
||||
|
||||
@@ -377,6 +377,11 @@ class Session:
|
||||
compression_anchor_message_key=None,
|
||||
compression_anchor_summary=None,
|
||||
pre_compression_snapshot: bool=False,
|
||||
context_engine=None,
|
||||
compression_anchor_engine=None,
|
||||
compression_anchor_mode=None,
|
||||
compression_anchor_details=None,
|
||||
context_engine_state=None,
|
||||
context_length=None, threshold_tokens=None,
|
||||
last_prompt_tokens=None,
|
||||
gateway_routing=None, gateway_routing_history=None,
|
||||
@@ -417,6 +422,11 @@ class Session:
|
||||
self.compression_anchor_message_key = compression_anchor_message_key
|
||||
self.compression_anchor_summary = compression_anchor_summary
|
||||
self.pre_compression_snapshot = bool(pre_compression_snapshot)
|
||||
self.context_engine = context_engine
|
||||
self.compression_anchor_engine = compression_anchor_engine
|
||||
self.compression_anchor_mode = compression_anchor_mode
|
||||
self.compression_anchor_details = compression_anchor_details if isinstance(compression_anchor_details, dict) else {}
|
||||
self.context_engine_state = context_engine_state if isinstance(context_engine_state, dict) else {}
|
||||
self.context_length = context_length
|
||||
self.threshold_tokens = threshold_tokens
|
||||
self.last_prompt_tokens = last_prompt_tokens
|
||||
@@ -481,6 +491,8 @@ class Session:
|
||||
'pending_user_message', 'pending_attachments', 'pending_started_at',
|
||||
'compression_anchor_visible_idx', 'compression_anchor_message_key',
|
||||
'compression_anchor_summary', 'pre_compression_snapshot',
|
||||
'context_engine', 'compression_anchor_engine', 'compression_anchor_mode',
|
||||
'compression_anchor_details', 'context_engine_state',
|
||||
'context_length', 'threshold_tokens', 'last_prompt_tokens',
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
@@ -671,6 +683,11 @@ class Session:
|
||||
'compression_anchor_message_key': self.compression_anchor_message_key,
|
||||
'compression_anchor_summary': self.compression_anchor_summary,
|
||||
'pre_compression_snapshot': self.pre_compression_snapshot,
|
||||
'context_engine': self.context_engine,
|
||||
'compression_anchor_engine': self.compression_anchor_engine,
|
||||
'compression_anchor_mode': self.compression_anchor_mode,
|
||||
'compression_anchor_details': self.compression_anchor_details,
|
||||
'context_engine_state': self.context_engine_state,
|
||||
'context_length': self.context_length,
|
||||
'threshold_tokens': self.threshold_tokens,
|
||||
'last_prompt_tokens': self.last_prompt_tokens,
|
||||
|
||||
@@ -196,6 +196,8 @@ const LOCALES = {
|
||||
conversation_cleared: 'Conversation cleared',
|
||||
command_label: 'Command',
|
||||
context_compaction_label: 'Context compaction',
|
||||
retrieval_context_label: 'Indexed context',
|
||||
retrieval_context_preview: 'Earlier messages are stored and retrievable with context tools',
|
||||
preserved_task_list_label: 'Preserved task list',
|
||||
reference_only_label: 'Reference only',
|
||||
model_usage: 'Usage: /model <name>',
|
||||
@@ -1418,6 +1420,8 @@ const LOCALES = {
|
||||
conversation_cleared: 'Conversazione cancellata',
|
||||
command_label: 'Comando',
|
||||
context_compaction_label: 'Compattazione contesto',
|
||||
retrieval_context_label: 'Contesto indicizzato',
|
||||
retrieval_context_preview: 'I messaggi precedenti sono archiviati e recuperabili con gli strumenti di contesto',
|
||||
preserved_task_list_label: 'Lista task preservata',
|
||||
reference_only_label: 'Solo riferimento',
|
||||
model_usage: 'Uso: /model <nome>',
|
||||
@@ -2632,6 +2636,8 @@ const LOCALES = {
|
||||
conversation_cleared: '会話をクリアしました',
|
||||
command_label: 'コマンド',
|
||||
context_compaction_label: 'コンテキスト圧縮',
|
||||
retrieval_context_label: 'インデックス済みコンテキスト',
|
||||
retrieval_context_preview: '以前のメッセージは保存され、コンテキストツールで取得できます',
|
||||
preserved_task_list_label: '保持されたタスクリスト',
|
||||
reference_only_label: '参照専用',
|
||||
model_usage: '使い方: /model <名前>',
|
||||
@@ -3886,6 +3892,8 @@ const LOCALES = {
|
||||
compress_failed_label: 'Ошибка сжатия',
|
||||
compress_running_label: 'Сжатие…',
|
||||
context_compaction_label: 'Сжатие контекста',
|
||||
retrieval_context_label: 'Индексированный контекст',
|
||||
retrieval_context_preview: 'Предыдущие сообщения сохранены и доступны через инструменты контекста',
|
||||
preserved_task_list_label: 'Сохранённый список задач',
|
||||
focus_label: 'Фокус',
|
||||
model_search_no_results: 'Модели не найдены',
|
||||
@@ -4996,6 +5004,8 @@ const LOCALES = {
|
||||
conversation_cleared: 'Conversación borrada',
|
||||
command_label: 'Comando',
|
||||
context_compaction_label: 'Compacción de contexto',
|
||||
retrieval_context_label: 'Contexto indexado',
|
||||
retrieval_context_preview: 'Los mensajes anteriores se almacenan y se pueden recuperar con herramientas de contexto',
|
||||
preserved_task_list_label: 'Lista de tareas conservada',
|
||||
reference_only_label: 'Solo referencia',
|
||||
model_usage: 'Uso: /model <name>',
|
||||
@@ -6123,6 +6133,8 @@ const LOCALES = {
|
||||
conversation_cleared: 'Konversation gelöscht',
|
||||
command_label: 'Befehl',
|
||||
context_compaction_label: 'Kontextkomprimierung',
|
||||
retrieval_context_label: 'Indizierter Kontext',
|
||||
retrieval_context_preview: 'Frühere Nachrichten sind gespeichert und über Kontextwerkzeuge abrufbar',
|
||||
preserved_task_list_label: 'Beibehaltene Aufgabenliste',
|
||||
reference_only_label: 'Nur Referenz',
|
||||
model_usage: 'Nutzung: /model <name>',
|
||||
@@ -7301,6 +7313,8 @@ const LOCALES = {
|
||||
conversation_cleared: '对话已清空',
|
||||
command_label: '命令',
|
||||
context_compaction_label: '上下文压缩',
|
||||
retrieval_context_label: '已索引上下文',
|
||||
retrieval_context_preview: '较早消息已存储,可通过上下文工具检索',
|
||||
preserved_task_list_label: '保留的任务列表',
|
||||
reference_only_label: '仅供参考',
|
||||
model_usage: '用法:/model <name>',
|
||||
@@ -10724,6 +10738,8 @@ const LOCALES = {
|
||||
conversation_cleared: '대화를 지웠습니다',
|
||||
command_label: '명령',
|
||||
context_compaction_label: 'Context compaction',
|
||||
retrieval_context_label: 'Indexed context',
|
||||
retrieval_context_preview: 'Earlier messages are stored and retrievable with context tools',
|
||||
preserved_task_list_label: '보존된 작업 목록',
|
||||
reference_only_label: 'Reference only',
|
||||
model_usage: 'Usage: /model <name>',
|
||||
|
||||
@@ -1849,6 +1849,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
phase:'done',
|
||||
automatic:true,
|
||||
message,
|
||||
engine:d.engine,
|
||||
mode:d.mode,
|
||||
details:d.details,
|
||||
summary:{headline:message},
|
||||
continuationSessionId:continuationSid,
|
||||
};
|
||||
|
||||
+33
-4
@@ -5096,9 +5096,10 @@ function _autoCompressionBaseDetail(state){
|
||||
: (String(state&&state.message||fallback).trim()||fallback);
|
||||
}
|
||||
function _autoCompressionPreviewText(state){
|
||||
const copy=_engineAwareCompressionCopy(String(state&&state.engine||_compressionEngineForSession()).toLowerCase(), String(state&&state.mode||_compressionModeForSession()).toLowerCase());
|
||||
const running=state&&state.phase==='running';
|
||||
const detail=_autoCompressionBaseDetail(state);
|
||||
if(!running) return (String(state&&state.summary?.headline||detail).trim()||detail);
|
||||
if(!running) return (String(state&&state.summary?.headline||copy.preview||detail).trim()||detail);
|
||||
const elapsedLabel=_compressionElapsedLabel(state);
|
||||
return [detail, elapsedLabel].filter(Boolean).join(' · ');
|
||||
}
|
||||
@@ -5112,13 +5113,14 @@ function _autoCompressionDetailText(state){
|
||||
return [base,handoff].filter(Boolean).join('\n');
|
||||
}
|
||||
function _autoCompressionCardsHtml(state){
|
||||
const copy=_engineAwareCompressionCopy(String(state&&state.engine||_compressionEngineForSession()).toLowerCase(), String(state&&state.mode||_compressionModeForSession()).toLowerCase());
|
||||
const running=state&&state.phase==='running';
|
||||
const preview=_autoCompressionPreviewText(state);
|
||||
const cardDetail=_autoCompressionDetailText(state);
|
||||
return `
|
||||
<div class="tool-card-row compression-card-row" data-compression-card="1">
|
||||
${_compressionStatusCardHtml({
|
||||
statusLabel: t('auto_compress_label'),
|
||||
statusLabel: (String(state&&state.engine||'').toLowerCase()==='lcm'||String(state&&state.mode||'').toLowerCase()==='lossless_retrieval')?copy.label:t('auto_compress_label'),
|
||||
previewText: preview,
|
||||
detail: cardDetail,
|
||||
icon: running ? '<span class="tool-card-running-dot"></span>' : li('check',13),
|
||||
@@ -5286,14 +5288,15 @@ function _latestCompressionReferenceMessage(messages, summaryText=''){
|
||||
return {message:null, rawIdx:-1};
|
||||
}
|
||||
function _compressionReferenceCardHtml(text, open=false){
|
||||
const copy=_engineAwareCompressionCopy();
|
||||
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-name">${esc(copy.label)}</span>
|
||||
<span class="tool-card-preview">${esc(copy.preview)} · ${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>
|
||||
@@ -5367,6 +5370,31 @@ function _formatMessageFooterTimestamp(tsVal){
|
||||
const opts={month:'short', day:'numeric', hour:'numeric', minute:'2-digit'};
|
||||
return fmt?fmt(date,opts):date.toLocaleString([], opts);
|
||||
}
|
||||
function _compressionEngineForSession(){
|
||||
return String(
|
||||
(S.session&&(
|
||||
S.session.compression_anchor_engine
|
||||
|| S.session.context_engine
|
||||
)) || 'compressor'
|
||||
).trim().toLowerCase() || 'compressor';
|
||||
}
|
||||
function _compressionModeForSession(){
|
||||
return String(
|
||||
(S.session&&S.session.compression_anchor_mode) || 'summary_compaction'
|
||||
).trim().toLowerCase() || 'summary_compaction';
|
||||
}
|
||||
function _engineAwareCompressionCopy(engine=_compressionEngineForSession(), mode=_compressionModeForSession()){
|
||||
if(engine==='lcm'||mode==='lossless_retrieval'){
|
||||
return {
|
||||
label:t('retrieval_context_label'),
|
||||
preview:t('retrieval_context_preview'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label:t('context_compaction_label'),
|
||||
preview:t('reference_only_label'),
|
||||
};
|
||||
}
|
||||
function _compressionStatusCardHtml({
|
||||
statusLabel,
|
||||
previewText,
|
||||
@@ -5946,6 +5974,7 @@ function renderMessages(options){
|
||||
}
|
||||
function _insertCompressionLikeNodeByRawIdx(node, rawIdx){
|
||||
if(!node) return;
|
||||
if(rawIdx<firstRenderedRawIdx) return;
|
||||
if(!renderVisWithIdx.length){
|
||||
inner.appendChild(node);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from api.compression_anchor import visible_messages_for_anchor
|
||||
from api.models import Session
|
||||
from api.streaming import _is_fallback_lifecycle_message
|
||||
|
||||
|
||||
@@ -475,6 +477,76 @@ def test_reference_message_inserted_before_future_assistant_anchor():
|
||||
assert helper.index("blocks.insertBefore(node, anchorSeg);") < helper.index("const userRow=userRows.get(anchorRawIdx);")
|
||||
|
||||
|
||||
def test_frontend_uses_context_engine_metadata_for_indexed_context_copy():
|
||||
src = _read("static/ui.js")
|
||||
i18n = _read("static/i18n.js")
|
||||
|
||||
assert "function _compressionEngineForSession" in src
|
||||
assert "S.session.compression_anchor_engine" in src
|
||||
assert "S.session.context_engine" in src
|
||||
assert "function _compressionModeForSession" in src
|
||||
assert "S.session.compression_anchor_mode" in src
|
||||
assert "function _engineAwareCompressionCopy" in src
|
||||
assert "mode==='lossless_retrieval'" in src
|
||||
assert "t('retrieval_context_label')" in src
|
||||
assert "t('retrieval_context_preview')" in src
|
||||
assert "retrieval_context_label" in i18n
|
||||
assert "retrieval_context_preview" in i18n
|
||||
|
||||
|
||||
def test_session_model_round_trips_context_engine_metadata(tmp_path, monkeypatch):
|
||||
import api.models as models
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
session_dir = state_dir / "sessions"
|
||||
session_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json")
|
||||
|
||||
session = Session(
|
||||
session_id="lcm_metadata",
|
||||
workspace=str(tmp_path),
|
||||
context_engine="lcm",
|
||||
compression_anchor_engine="lcm",
|
||||
compression_anchor_mode="lossless_retrieval",
|
||||
compression_anchor_details={"retrieval_tools": ["lcm_grep"]},
|
||||
context_engine_state={"status": "indexed"},
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
|
||||
loaded = Session.load("lcm_metadata")
|
||||
assert loaded.context_engine == "lcm"
|
||||
assert loaded.compression_anchor_engine == "lcm"
|
||||
assert loaded.compression_anchor_mode == "lossless_retrieval"
|
||||
assert loaded.compression_anchor_details == {"retrieval_tools": ["lcm_grep"]}
|
||||
assert loaded.context_engine_state == {"status": "indexed"}
|
||||
|
||||
|
||||
def test_backend_auto_anchor_count_excludes_compaction_marker_cards():
|
||||
messages = [
|
||||
{"role": "user", "content": "before compression"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] summary"},
|
||||
{"role": "assistant", "content": "after compression"},
|
||||
{"role": "tool", "content": "hidden tool output"},
|
||||
{"role": "user", "content": "[Your active task list was preserved across context compression]"},
|
||||
]
|
||||
|
||||
visible = visible_messages_for_anchor(messages, auto_compression=True)
|
||||
|
||||
assert [m["content"] for m in visible] == ["before compression", "after compression"]
|
||||
|
||||
|
||||
def test_frontend_reference_insertion_skips_when_reference_is_before_render_window():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _insertCompressionLikeNodeByRawIdx")
|
||||
assert start != -1, "raw-index insertion helper not found"
|
||||
end = src.find("const preservedOnlyNode=", start)
|
||||
assert end != -1, "raw-index insertion helper end not found"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "if(rawIdx<firstRenderedRawIdx) return;" in helper
|
||||
|
||||
|
||||
def test_reference_message_selection_prefers_latest_matching_marker():
|
||||
src = _read("static/ui.js")
|
||||
start = src.find("function _latestCompressionReferenceMessage")
|
||||
|
||||
Reference in New Issue
Block a user