Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude b84b9d3ee7 feat: composer context token ring, memory chip, compaction banner (fixes #196)
Add a server-driven context budget indicator to the chat composer:
- GET /api/chat/context-stats returns a char-based token estimate with a
  deterministic breakdown (system/tools/messages/memory).
- Token ring wraps the send button with healthy/warning/critical/over
  states, dashed arc for estimates, and a breakdown tooltip.
- Compaction banner surfaces at >=85% usage; a memory chip shows retrieved
  memory count per turn and opens the existing memory panel.
- Ring refreshes on run completion, WS context_update/context_compacted
  events, and a 5s poll fallback.
- i18n keys added for en/es/fr.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-14 07:27:36 +00:00
4 changed files with 649 additions and 7 deletions

No files matched your search

+88
View File
@@ -671,6 +671,93 @@ async def _run_and_broadcast(
pass
DEFAULT_CONTEXT_LIMIT = 128000
_TOKENS_PER_CHAR = 0.25
_SYSTEM_BASELINE_TOKENS = 800
_TOOLS_BASELINE_TOKENS = 400
def _estimate_context_stats(
messages: List[Dict[str, Any]],
*,
session_id: str,
limit: int = DEFAULT_CONTEXT_LIMIT,
model: Optional[str] = None,
) -> Dict[str, Any]:
"""Compute a char-based context usage estimate for a session.
No tokenizer dependency: approximates tokens as ``chars * 0.25`` and
adds fixed system/tools baselines. Returns a wire-safe dict with a
deterministic, sorted breakdown. ``estimate`` is always ``True`` since
this is a heuristic, not a provider-reported count.
"""
message_chars = 0
tool_chars = 0
memory_chars = 0
for m in messages:
content = m.get("content") or ""
message_chars += len(str(content))
tool_calls = m.get("toolCalls") or m.get("tool_calls") or []
for tc in tool_calls:
tool_chars += len(str(tc.get("args", "")))
tool_chars += len(str(tc.get("result", "")))
for el in m.get("elements") or []:
memory_chars += len(str(el.get("url", "")))
system_tokens = _SYSTEM_BASELINE_TOKENS
tools_tokens = _TOOLS_BASELINE_TOKENS + int(tool_chars * _TOKENS_PER_CHAR)
messages_tokens = int(message_chars * _TOKENS_PER_CHAR)
memory_tokens = int(memory_chars * _TOKENS_PER_CHAR)
breakdown = {
"memory": memory_tokens,
"messages": messages_tokens,
"system": system_tokens,
"tools": tools_tokens,
}
used = system_tokens + tools_tokens + messages_tokens + memory_tokens
safe_limit = limit if limit and limit > 0 else DEFAULT_CONTEXT_LIMIT
usage_pct = round((used / safe_limit) * 100, 1) if safe_limit else 0.0
result: Dict[str, Any] = {
"breakdown": breakdown,
"estimate": True,
"limit": limit,
"session_id": session_id,
"usage_pct": usage_pct,
"used": used,
}
if model:
result["model"] = model
return result
async def _chat_context_stats(request: Request) -> JSONResponse:
"""GET /api/chat/context-stats?session_id= — context budget estimate.
Config-driven fallback for the composer token ring. Computes a
char-based estimate from persisted messages; unknown sessions return
a zero-usage estimate rather than 404 so the UI can always render.
"""
session_id = request.query_params.get("session_id", "")
try:
limit = int(request.query_params.get("limit", str(DEFAULT_CONTEXT_LIMIT)))
except (TypeError, ValueError):
limit = DEFAULT_CONTEXT_LIMIT
messages: List[Dict[str, Any]] = []
if session_id:
try:
from praisonaiui.server import _datastore
messages = await _datastore.get_messages(session_id) or []
except Exception:
messages = []
stats = _estimate_context_stats(messages, session_id=session_id, limit=limit)
return JSONResponse(stats)
async def _chat_history(request: Request) -> JSONResponse:
"""GET /api/chat/history/{session_id} — get message history."""
session_id = request.path_params["session_id"]
@@ -854,6 +941,7 @@ class ChatFeature(BaseFeatureProtocol):
def routes(self) -> List[Route]:
return [
Route("/api/chat/send", _chat_send, methods=["POST"]),
Route("/api/chat/context-stats", _chat_context_stats, methods=["GET"]),
Route("/api/chat/history/{session_id}", _chat_history, methods=["GET"]),
Route("/api/chat/abort", _chat_abort, methods=["POST"]),
WebSocketRoute("/api/chat/ws", _chat_ws),
+24
View File
@@ -73,6 +73,14 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Type your message...",
"chat.send": "Send",
"chat.thinking": "Thinking...",
"chat.context.healthy": "Context healthy",
"chat.context.warning": "Approaching context limit",
"chat.context.critical": "Context nearly full",
"chat.context.estimate": "Estimate",
"chat.compaction.banner": "Context nearly full \u2014 older messages may be summarized on next send",
"chat.compaction.compacted": "Context compacted",
"chat.memory.chip": "{count} memories",
"chat.memory.empty_turn": "No memories for this turn",
"nav.dashboard": "Dashboard",
"nav.agents": "Agents",
"nav.settings": "Settings",
@@ -86,6 +94,14 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Escribe tu mensaje...",
"chat.send": "Enviar",
"chat.thinking": "Pensando...",
"chat.context.healthy": "Contexto saludable",
"chat.context.warning": "Acercándose al límite de contexto",
"chat.context.critical": "Contexto casi lleno",
"chat.context.estimate": "Estimación",
"chat.compaction.banner": "Contexto casi lleno \u2014 los mensajes antiguos pueden resumirse al enviar",
"chat.compaction.compacted": "Contexto compactado",
"chat.memory.chip": "{count} recuerdos",
"chat.memory.empty_turn": "Sin recuerdos para este turno",
"nav.dashboard": "Panel",
"nav.agents": "Agentes",
"nav.settings": "Configuración",
@@ -99,6 +115,14 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Tapez votre message...",
"chat.send": "Envoyer",
"chat.thinking": "Réflexion...",
"chat.context.healthy": "Contexte sain",
"chat.context.warning": "Approche de la limite de contexte",
"chat.context.critical": "Contexte presque plein",
"chat.context.estimate": "Estimation",
"chat.compaction.banner": "Contexte presque plein \u2014 les anciens messages peuvent être résumés au prochain envoi",
"chat.compaction.compacted": "Contexte compacté",
"chat.memory.chip": "{count} souvenirs",
"chat.memory.empty_turn": "Aucun souvenir pour ce tour",
"nav.dashboard": "Tableau de bord",
"nav.agents": "Agents",
"nav.settings": "Paramètres",
@@ -29,6 +29,12 @@ let currentDeltaText = '';
let pendingMediaElements = [];
let pendingAttachments = []; // { id, filename, content_type, preview_url }
// ── Context Ring State ───────────────────────────────────────────
const RING_CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 → ~125.66
let contextPollTimer = null;
let contextRefreshDebounce = null;
let lastContextPct = 0;
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
let isMinimized = false;
@@ -277,6 +283,62 @@ function injectStyles() {
.chat-metrics { display:flex; gap:12px; padding:4px 12px; font-size:11px; color:var(--db-text-dim); }
.chat-metrics span { display:flex; align-items:center; gap:4px; }
/* ── Context Token Ring ─────────────────────────────────────── */
.chat-ring-wrap { position:relative; width:48px; height:48px; min-width:48px; flex-shrink:0; }
.chat-ring-wrap svg { position:absolute; inset:0; width:48px; height:48px; transform:rotate(-90deg); }
.chat-ring-track { fill:none; stroke:var(--db-border,#333); stroke-width:4; }
.chat-ring-arc { fill:none; stroke-width:4; stroke-linecap:round; transition:stroke-dashoffset .2s ease, stroke .2s ease; }
.chat-ring-healthy .chat-ring-arc { stroke:#22c55e; }
.chat-ring-warning .chat-ring-arc { stroke:#eab308; }
.chat-ring-critical .chat-ring-arc { stroke:#eab308; }
.chat-ring-over .chat-ring-arc { stroke:#ef4444; }
.chat-ring-estimate .chat-ring-arc { stroke-dasharray:4 3 !important; }
.chat-ring-over .chat-ring-arc { animation:ring-pulse 1.1s ease-in-out infinite; }
@keyframes ring-pulse { 50% { opacity:.4; } }
.chat-ring-send { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:32px; height:32px; border-radius:50%; border:none; background:var(--db-accent); color:#fff; cursor:pointer; font-size:15px; display:flex; align-items:center; justify-content:center; padding:0; transition:filter .15s; }
.chat-ring-send:hover { filter:brightness(1.1); }
.chat-ring-send:disabled { opacity:.5; cursor:not-allowed; }
.chat-ring-tooltip { position:absolute; bottom:56px; right:0; z-index:20; display:none; min-width:180px; padding:10px 12px; background:var(--db-card-bg,#27272a); border:1px solid var(--db-border); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); font-size:11px; color:var(--db-text); }
.chat-ring-wrap:hover .chat-ring-tooltip, .chat-ring-wrap:focus-within .chat-ring-tooltip { display:block; }
.chat-ring-tooltip h5 { margin:0 0 6px; font-size:11px; font-weight:600; }
.chat-ring-tt-row { display:flex; align-items:center; gap:6px; margin:3px 0; }
.chat-ring-tt-row .lbl { width:64px; color:var(--db-text-dim); }
.chat-ring-tt-bar { flex:1; height:6px; border-radius:3px; background:rgba(var(--db-accent-rgb,100,100,255),.15); overflow:hidden; }
.chat-ring-tt-bar > i { display:block; height:100%; background:var(--db-accent); }
.chat-ring-tt-val { width:36px; text-align:right; color:var(--db-text-dim); }
.chat-ring-tt-total { margin-top:6px; padding-top:6px; border-top:1px solid var(--db-border); font-weight:600; }
/* ── Compaction Banner ──────────────────────────────────────── */
.chat-compaction-banner { display:none; align-items:center; gap:8px; padding:8px 16px; border-bottom:1px solid rgba(234,179,8,.3); background:rgba(234,179,8,.1); color:#eab308; font-size:12.5px; }
.chat-compaction-banner.open { display:flex; }
.chat-compaction-banner.critical { border-bottom-color:rgba(239,68,68,.35); background:rgba(239,68,68,.1); color:#ef4444; }
.chat-compaction-banner .cb-text { flex:1; }
.chat-compaction-banner .cb-dismiss { background:none; border:none; cursor:pointer; color:inherit; font-size:14px; padding:0 4px; line-height:1; }
/* ── Memory Chip ────────────────────────────────────────────── */
.chat-compose-footer { display:flex; align-items:center; gap:12px; padding:6px 2px 0; }
.chat-memory-chip { display:none; align-items:center; gap:5px; padding:3px 10px; border-radius:14px; border:1px solid rgba(147,51,234,.25); background:rgba(147,51,234,.08); color:rgba(147,51,234,.9); font-size:12px; cursor:pointer; transition:all .15s; }
.chat-memory-chip.visible { display:inline-flex; }
.chat-memory-chip:hover { background:rgba(147,51,234,.16); }
.chat-memory-chip.loading { opacity:.6; }
/* ── Enhanced memory cards ──────────────────────────────────── */
.chat-mem-item .mem-head { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.chat-mem-item .mem-badge { font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(147,51,234,.12); color:rgba(147,51,234,.85); font-weight:600; text-transform:uppercase; }
.chat-mem-item .mem-time { font-size:10px; color:var(--db-text-dim); margin-left:auto; }
.chat-mem-item .mem-relevance { height:5px; border-radius:3px; background:rgba(147,51,234,.12); overflow:hidden; margin-top:5px; }
.chat-mem-item .mem-relevance > i { display:block; height:100%; background:rgba(147,51,234,.7); }
.chat-mem-item .mem-snippet { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
@media (max-width:520px) {
.chat-ring-wrap, .chat-ring-wrap svg { width:36px; height:36px; min-width:36px; }
.chat-ring-send { width:24px; height:24px; font-size:12px; }
}
@media (prefers-reduced-motion:reduce) {
.chat-ring-arc { transition:none; }
.chat-ring-over .chat-ring-arc { animation:none; }
}
.chat-memory-panel { display:none; border-bottom:1px solid var(--db-border); background:var(--db-sidebar-bg,var(--db-card-bg)); max-height:300px; overflow-y:auto; padding:10px 14px; font-size:13px; }
.chat-memory-panel.open { display:block; }
.chat-memory-panel-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
@@ -685,6 +747,11 @@ export async function render(container) {
<button class="chat-icon-btn danger" id="chat-abort-btn" style="display:none" title="Stop">⬛ Stop</button>
</div>
</div>
<div class="chat-compaction-banner" id="chat-compaction-banner">
<span>⚠</span>
<span class="cb-text" id="chat-compaction-text"></span>
<button class="cb-dismiss" id="chat-compaction-dismiss" title="Dismiss" aria-label="Dismiss">✕</button>
</div>
<div class="chat-memory-panel" id="chat-memory-panel">
<div class="chat-memory-panel-header">
<h4>🧠 Agent Memories</h4>
@@ -709,9 +776,22 @@ export async function render(container) {
<input type="file" id="chat-file-input" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,image/*,application/pdf" multiple style="display:none" />
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
<div class="chat-compose-actions">
<button class="chat-send-btn" id="chat-send-btn">Send ↵</button>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
<svg viewBox="0 0 48 48" aria-hidden="true">
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
<circle class="chat-ring-arc" id="chat-ring-arc" cx="24" cy="24" r="20"
stroke-dasharray="125.66" stroke-dashoffset="125.66"></circle>
</svg>
<button class="chat-ring-send" id="chat-send-btn" title="Send"
role="progressbar" aria-label="Send message"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">➤</button>
<div class="chat-ring-tooltip" id="chat-ring-tooltip"></div>
</div>
</div>
</div>
<div class="chat-compose-footer">
<button class="chat-memory-chip" id="chat-memory-chip" aria-expanded="false"></button>
</div>
</div>
</div>
</div>
@@ -766,6 +846,15 @@ export async function render(container) {
if (panel) panel.classList.remove('open');
});
const memChip = document.getElementById('chat-memory-chip');
if (memChip) memChip.addEventListener('click', () => toggleMemoryPanel());
const compactionDismiss = document.getElementById('chat-compaction-dismiss');
if (compactionDismiss) compactionDismiss.addEventListener('click', dismissCompactionBanner);
// Initial context ring render (estimate for empty/new session)
refreshContextStats();
startContextPoll();
await loadAgents();
// Pre-select agent if passed via URL query param (from Agents page "Run" button)
@@ -798,6 +887,8 @@ export async function render(container) {
export function cleanup() {
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
stopContextPoll();
if (contextRefreshDebounce) { clearTimeout(contextRefreshDebounce); contextRefreshDebounce = null; }
if (sessionSelectHandler) {
window.removeEventListener('aiui:session-select', sessionSelectHandler);
sessionSelectHandler = null;
@@ -902,6 +993,9 @@ async function loadSessions() {
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
// E13: reset context ring + memory chip for the new session
refreshContextStats();
refreshMemoryChip();
const messagesEl = document.getElementById('chat-messages');
const welcome = document.getElementById('chat-welcome');
if (welcome) welcome.style.display = 'none';
@@ -1087,6 +1181,24 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
// Refresh context ring + memory chip after turn completes
scheduleContextRefresh();
refreshMemoryChip();
break;
case 'context_update':
updateContextRing({
used: data.used,
limit: data.limit,
usage_pct: data.usage_pct,
breakdown: data.breakdown,
estimate: data.estimate,
});
break;
case 'context_compacted':
flashCompactionCompacted(data);
scheduleContextRefresh();
break;
case 'run_started':
@@ -1171,6 +1283,8 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
scheduleContextRefresh();
refreshMemoryChip();
break;
case 'team_run_error':
@@ -1712,6 +1826,191 @@ function completeMemoryIndicator() {
}
}
// ── Context Token Ring ──────────────────────────────────────────
function ringStateFor(pct) {
if (pct >= 95) return 'over';
if (pct >= 85) return 'critical';
if (pct >= 60) return 'warning';
return 'healthy';
}
function updateContextRing(stats) {
const wrap = document.getElementById('chat-ring-wrap');
const arc = document.getElementById('chat-ring-arc');
const sendBtn = document.getElementById('chat-send-btn');
if (!wrap || !arc) return;
const used = Number(stats.used) || 0;
const limit = Number(stats.limit) || 0;
const estimate = !!stats.estimate;
// E2: unknown/zero limit — hide ring, keep send button usable
if (!limit || limit <= 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = '';
let pct = stats.usage_pct != null ? Number(stats.usage_pct) : (used / limit) * 100;
if (!isFinite(pct) || pct < 0) pct = 0;
const clamped = Math.min(pct, 100);
lastContextPct = pct;
const state = ringStateFor(pct);
wrap.className = 'chat-ring-wrap chat-ring-' + state + (estimate ? ' chat-ring-estimate' : '');
arc.setAttribute('stroke-dasharray', RING_CIRCUMFERENCE.toFixed(2));
arc.setAttribute('stroke-dashoffset', (RING_CIRCUMFERENCE * (1 - clamped / 100)).toFixed(2));
if (sendBtn) {
sendBtn.setAttribute('aria-valuenow', String(Math.round(pct)));
sendBtn.setAttribute('aria-valuemax', '100');
}
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
}
function updateRingTooltip(stats, pct) {
const tip = document.getElementById('chat-ring-tooltip');
if (!tip) return;
const b = stats.breakdown || {};
const used = Number(stats.used) || 0;
const limit = Number(stats.limit) || 0;
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n);
const rows = [
['System', b.system || 0],
['Tools', b.tools || 0],
['Messages', b.messages || 0],
['Memory', b.memory || 0],
];
const maxVal = Math.max(1, ...rows.map((r) => r[1]));
const title = stats.estimate ? 'Context breakdown (estimate)' : 'Context breakdown';
tip.innerHTML =
'<h5>' + title + '</h5>' +
rows.map(([lbl, val]) =>
'<div class="chat-ring-tt-row">' +
'<span class="lbl">' + lbl + '</span>' +
'<span class="chat-ring-tt-bar"><i style="width:' + Math.round((val / maxVal) * 100) + '%"></i></span>' +
'<span class="chat-ring-tt-val">' + fmt(val) + '</span>' +
'</div>'
).join('') +
'<div class="chat-ring-tt-total">Total: ' + fmt(used) + ' / ' + fmt(limit) + ' (' + Math.round(pct) + '%)</div>';
}
function updateCompactionBanner(pct, estimate) {
const banner = document.getElementById('chat-compaction-banner');
const textEl = document.getElementById('chat-compaction-text');
if (!banner || !textEl) return;
if (pct < 85) {
banner.classList.remove('open', 'critical');
return;
}
// E4: respect per-session dismissal until usage drops below 85 then rises
if (sessionStorage.getItem('aiui-compaction-dismissed') === (currentSessionId || 'default')) {
if (pct < 85) sessionStorage.removeItem('aiui-compaction-dismissed');
else return;
}
banner.classList.add('open');
banner.classList.toggle('critical', pct >= 95);
banner.setAttribute('role', pct >= 95 ? 'alert' : 'status');
textEl.textContent = pct >= 95
? 'Context ' + Math.round(pct) + '% — older messages will be summarized on next send'
: 'Context ' + Math.round(pct) + '% — older messages may be summarized on next send';
}
function dismissCompactionBanner() {
const banner = document.getElementById('chat-compaction-banner');
if (banner) banner.classList.remove('open', 'critical');
sessionStorage.setItem('aiui-compaction-dismissed', currentSessionId || 'default');
}
// E21: flash a transient "Context compacted" banner after WS event
function flashCompactionCompacted(data) {
const banner = document.getElementById('chat-compaction-banner');
const textEl = document.getElementById('chat-compaction-text');
if (!banner || !textEl) return;
banner.classList.add('open');
banner.classList.remove('critical');
banner.setAttribute('role', 'status');
const removed = data && data.removed_tokens ? ' (' + data.removed_tokens + ' tokens freed)' : '';
textEl.textContent = 'Context compacted' + removed;
sessionStorage.removeItem('aiui-compaction-dismissed');
setTimeout(() => {
if (lastContextPct < 85) banner.classList.remove('open');
}, 4000);
}
async function refreshContextStats() {
try {
const qs = currentSessionId ? '?session_id=' + encodeURIComponent(currentSessionId) : '';
const resp = await fetch('/api/chat/context-stats' + qs);
if (!resp.ok) return; // E1: keep last estimate on error
const stats = await resp.json();
updateContextRing(stats);
} catch (e) {
// E1/E11: network error — ring keeps prior state
}
}
// E10: debounce rapid refreshes (e.g. multiple completions)
function scheduleContextRefresh() {
if (contextRefreshDebounce) clearTimeout(contextRefreshDebounce);
contextRefreshDebounce = setTimeout(refreshContextStats, 100);
}
// E11: poll fallback when WS context_update isn't emitted by backend
function startContextPoll() {
if (contextPollTimer) clearInterval(contextPollTimer);
contextPollTimer = setInterval(refreshContextStats, 5000);
}
function stopContextPoll() {
if (contextPollTimer) { clearInterval(contextPollTimer); contextPollTimer = null; }
}
// ── Memory Chip ─────────────────────────────────────────────────
function updateMemoryChip(count, loading) {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
if (loading) {
chip.classList.add('visible', 'loading');
chip.innerHTML = '🧠 …';
return;
}
chip.classList.remove('loading');
const n = Number(count) || 0;
if (n <= 0) {
// E5: hide chip when no memories retrieved this turn
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
chip.classList.add('visible');
chip.innerHTML = '🧠 ' + n + ' ' + (n === 1 ? 'memory' : 'memories');
}
async function refreshMemoryChip() {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
updateMemoryChip(0, true);
try {
const endpoint = currentSessionId
? '/api/memory/session/' + encodeURIComponent(currentSessionId)
: '/api/memory';
let resp = await fetch(endpoint);
if (!resp.ok) resp = await fetch('/api/memory');
if (!resp.ok) { chip.classList.remove('loading'); chip.innerHTML = '🧠 —'; chip.classList.add('visible'); return; }
const data = await resp.json();
const items = data.memories || [];
updateMemoryChip(items.length, false);
} catch (e) {
// E6: memory search failed — muted dash
chip.classList.remove('loading');
chip.innerHTML = '🧠 —';
}
}
function showStreamMetrics(metrics) {
let metricsEl = document.getElementById('chat-stream-metrics');
if (!metricsEl) {
@@ -1734,6 +2033,9 @@ function toggleMemoryPanel() {
const panel = document.getElementById('chat-memory-panel');
if (!panel) return;
const isOpen = panel.classList.toggle('open');
// E17: brain button + chip open the same panel; keep chip aria in sync
const chip = document.getElementById('chat-memory-chip');
if (chip) chip.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadMemories();
}
@@ -1763,12 +2065,26 @@ async function loadMemories() {
return;
}
listEl.innerHTML = items.map(m => `
listEl.innerHTML = items.map(m => {
const type = m.memory_type || m.type || 'long';
const snippet = m.text || m.value || m.content || '';
const relevance = Number(m.relevance != null ? m.relevance : m.score);
const relPct = isFinite(relevance) ? Math.round(Math.max(0, Math.min(1, relevance)) * 100) : null;
const ts = m.created_at || m.timestamp;
const relBar = relPct != null
? `<div class="mem-relevance" title="Relevance ${relPct}%"><i style="width:${relPct}%"></i></div>`
: '';
const timeStr = ts ? timeAgo(ts) : '';
return `
<div class="chat-mem-item">
<div class="mem-type">${m.memory_type || 'long'}</div>
<div class="mem-text">${escapeHtml(m.text || '')}</div>
</div>
`).join('');
<div class="mem-head">
<span class="mem-badge">${escapeHtml(type)}</span>
${timeStr ? `<span class="mem-time">${escapeHtml(timeStr)}</span>` : ''}
</div>
<div class="mem-text mem-snippet">${escapeHtml(snippet)}</div>
${relBar}
</div>`;
}).join('');
} catch (err) {
listEl.innerHTML = `<span class="chat-mem-empty">Error: ${err.message}</span>`;
}
@@ -2016,7 +2332,7 @@ function refreshStreamingUI() {
const abortBtn = document.getElementById('chat-abort-btn');
const sendBtn = document.getElementById('chat-send-btn');
if (abortBtn) abortBtn.style.display = inStreamingSession ? '' : 'none';
if (sendBtn) sendBtn.textContent = inStreamingSession ? 'Queue ↵' : 'Send ↵';
if (sendBtn) sendBtn.title = inStreamingSession ? 'Queue message' : 'Send message';
// Update status badge too
if (!inStreamingSession && isStreaming) {
setStatus('connected'); // other session streaming, but we're not in it
+214
View File
@@ -0,0 +1,214 @@
"""Unit tests for the composer context token ring (STITCH-002, #196).
Covers:
* Backend char-based context estimator + /api/chat/context-stats route.
* Deterministic, wire-safe serialisation.
* Frontend ring threshold state machine + component presence in chat.js.
* i18n keys for ring/banner/chip.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from praisonaiui.features.chat import (
DEFAULT_CONTEXT_LIMIT,
_chat_context_stats,
_estimate_context_stats,
)
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHAT_JS = FRONTEND / "views" / "chat.js"
class _FakeRequest:
def __init__(self, params: dict[str, str]):
self.query_params = params
class TestEstimator:
def test_empty_session_baseline(self):
stats = _estimate_context_stats([], session_id="s1")
assert stats["session_id"] == "s1"
assert stats["limit"] == DEFAULT_CONTEXT_LIMIT
assert stats["estimate"] is True
# System + tools baselines only
assert stats["breakdown"]["messages"] == 0
assert stats["breakdown"]["memory"] == 0
assert stats["used"] > 0
def test_messages_increase_usage(self):
small = _estimate_context_stats(
[{"role": "user", "content": "hi"}], session_id="s"
)
big = _estimate_context_stats(
[{"role": "user", "content": "x" * 4000}], session_id="s"
)
assert big["used"] > small["used"]
assert big["breakdown"]["messages"] > small["breakdown"]["messages"]
def test_tool_calls_count_towards_tools(self):
msgs = [
{
"role": "assistant",
"content": "done",
"toolCalls": [{"args": "q" * 400, "result": "r" * 400}],
}
]
stats = _estimate_context_stats(msgs, session_id="s")
baseline = _estimate_context_stats([], session_id="s")
assert stats["breakdown"]["tools"] > baseline["breakdown"]["tools"]
def test_usage_pct_matches_used_over_limit(self):
stats = _estimate_context_stats(
[{"role": "user", "content": "x" * 8000}], session_id="s", limit=100000
)
expected = round((stats["used"] / 100000) * 100, 1)
assert stats["usage_pct"] == expected
def test_zero_limit_falls_back_to_default_for_pct(self):
stats = _estimate_context_stats([], session_id="s", limit=0)
# limit echoed as-is, but pct computed against a safe default (no div/0)
assert stats["limit"] == 0
assert stats["usage_pct"] >= 0
def test_breakdown_keys_sorted_deterministic(self):
stats = _estimate_context_stats([], session_id="s")
keys = list(stats["breakdown"].keys())
assert keys == sorted(keys)
assert set(keys) == {"memory", "messages", "system", "tools"}
def test_top_level_keys_sorted(self):
stats = _estimate_context_stats([], session_id="s")
keys = list(stats.keys())
assert keys == sorted(keys)
class TestContextStatsRoute:
def test_unknown_session_returns_estimate_not_404(self):
resp = asyncio.run(_chat_context_stats(_FakeRequest({"session_id": "nope"})))
assert resp.status_code == 200
def test_missing_session_id_ok(self):
resp = asyncio.run(_chat_context_stats(_FakeRequest({})))
assert resp.status_code == 200
def test_invalid_limit_falls_back(self):
resp = asyncio.run(
_chat_context_stats(_FakeRequest({"session_id": "s", "limit": "abc"}))
)
assert resp.status_code == 200
class TestRingThresholds:
"""Mirror the JS ringStateFor thresholds against the spec table."""
def _state(self, pct: float) -> str:
if pct >= 95:
return "over"
if pct >= 85:
return "critical"
if pct >= 60:
return "warning"
return "healthy"
def test_healthy_below_60(self):
assert self._state(33) == "healthy"
assert self._state(59.9) == "healthy"
def test_warning_60_to_85(self):
assert self._state(60) == "warning"
assert self._state(84.9) == "warning"
def test_critical_85_to_95(self):
assert self._state(85) == "critical"
assert self._state(94.9) == "critical"
def test_over_at_95(self):
assert self._state(95) == "over"
assert self._state(120) == "over"
class TestChatJsComponents:
def test_ring_dom_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-ring-wrap"' in src
assert 'id="chat-ring-arc"' in src
assert 'id="chat-ring-tooltip"' in src
def test_send_button_inside_ring_preserved_id(self):
src = CHAT_JS.read_text()
assert 'class="chat-ring-send" id="chat-send-btn"' in src
assert 'aria-valuenow' in src
assert 'aria-valuemax' in src
def test_compaction_banner_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-compaction-banner"' in src
assert "function updateCompactionBanner(" in src
assert "function dismissCompactionBanner(" in src
def test_memory_chip_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-memory-chip"' in src
assert "function refreshMemoryChip(" in src
def test_ring_state_machine_thresholds(self):
src = CHAT_JS.read_text()
assert "function ringStateFor(" in src
assert "pct >= 95" in src
assert "pct >= 85" in src
assert "pct >= 60" in src
def test_ws_context_events_handled(self):
src = CHAT_JS.read_text()
assert "case 'context_update':" in src
assert "case 'context_compacted':" in src
def test_poll_fallback_and_debounce(self):
src = CHAT_JS.read_text()
assert "function startContextPoll(" in src
assert "function scheduleContextRefresh(" in src
def test_existing_memory_panel_and_metrics_preserved(self):
src = CHAT_JS.read_text()
# Regression: do not remove existing shipped elements
assert 'id="chat-memory-panel"' in src
assert 'id="chat-memory-btn"' in src
assert "function showStreamMetrics(" in src
class TestI18nKeys:
KEYS = [
"chat.context.healthy",
"chat.context.warning",
"chat.context.critical",
"chat.context.estimate",
"chat.compaction.banner",
"chat.compaction.compacted",
"chat.memory.chip",
"chat.memory.empty_turn",
]
def test_all_keys_present_en(self):
mgr = JSONLocaleManager()
strings = mgr.get_strings("en")
for k in self.KEYS:
assert k in strings, f"missing en key: {k}"
def test_keys_present_all_locales(self):
mgr = JSONLocaleManager()
for locale in ("en", "es", "fr"):
strings = mgr.get_strings(locale)
for k in self.KEYS:
assert k in strings, f"missing {locale} key: {k}"
def test_memory_chip_interpolates_count(self):
mgr = JSONLocaleManager()
out = mgr.t("chat.memory.chip", locale="en", count=3)
assert "3" in out