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
MervinPraison 2ebb56f609 Release v0.3.132 2026-07-14 02:00:30 +00:00
praisonai-triage-agent[bot] 6725a5bdf7 Merge pull request #194 from MervinPraison/claude/issue-191-20260713-0819
fix: honor aiui run --port for gateway examples that delegate to async main()
2026-07-13 10:12:12 +00:00
MervinPraison f1f953be76 Release v0.3.131 2026-07-13 10:08:48 +00:00
praisonai-triage-agent[bot] ee994b586d Merge pull request #193 from MervinPraison/claude/issue-192-20260713-120000
fix: isolate OPENAI_API_KEY in realtime lifecycle test
2026-07-13 10:04:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 44428e5caf fix: propagate --port/--host to gateway main() delegation (fixes #191)
Set PORT/HOST env before delegating to a user-defined async main() so
gateway examples that read os.getenv("PORT") honor the CLI --port flag.
Explicit env set by the user takes precedence over the CLI flag.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-13 08:25:16 +00:00
MervinPraisonandClaude 1748d6d023 fix: isolate OPENAI_API_KEY in realtime session lifecycle test (fixes #192)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-13 08:21:25 +00:00
MervinPraison a45c3af006 Release v0.3.130 2026-07-10 10:49:34 +00:00
praisonai-triage-agent[bot] b54d94dd56 Merge pull request #190 from MervinPraison/claude/issue-189-20260710-0829
feat: restore Channels dashboard Add/Delete/Test CRUD UI
2026-07-10 10:46:59 +00:00
praisonai-triage-agent[bot]andMervin Praison d9e8b12628 feat: restore Channels dashboard Add/Delete/Test CRUD UI (fixes #189)
Restore the + Add Channel modal, per-platform credential fields, setup
hints, and card actions (Enable/Disable, Restart, Test, Delete) that were
removed in the 3961508 refactor. Aligned with #176 secret policy (env
references, no plaintext prefill, inline 400 errors surfaced) and #172
dropdown contrast fix (.db-form-select). Adds POST /api/channels preset to
the API Explorer and neutralizes the legacy read-only plugin so it no
longer clobbers the managed view.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-10 08:34:43 +00:00
15 changed files with 1131 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.129"
version = "0.3.132"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.129"
__version__ = "0.3.132"
+8
View File
@@ -1596,10 +1596,18 @@ def run(
# Run gateway - check if user module defines its own main function
import asyncio
import inspect
import os
if hasattr(user_module, 'main') and callable(getattr(user_module, 'main')):
# Call user's main function directly (gateway pattern)
main_func = getattr(user_module, 'main')
# Propagate CLI --port/--host to gateway examples that read the
# PORT/HOST environment variables. Explicit env set by the user
# takes precedence over the CLI flag.
if "PORT" not in os.environ:
os.environ["PORT"] = str(actual_port)
if "HOST" not in os.environ:
os.environ["HOST"] = host
if inspect.iscoroutinefunction(main_func):
console.print("[yellow]ℹ️[/yellow] Delegating to user-defined async main()")
asyncio.run(main_func())
+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",
+8 -1
View File
@@ -116,7 +116,14 @@ class OpenAIRealtimeManager(RealtimeProtocol):
logger.debug(f"Audio chunk for session {session_id}: {len(audio_data)} bytes")
async def receive_audio(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
"""Receive events from OpenAI realtime session."""
"""Receive events from OpenAI realtime session.
First-event contract:
- Unknown session -> ``error`` (Session not found)
- ``OPENAI_API_KEY`` set -> ``session.ready`` (WebRTC handshake)
- Integrated mode, no key -> ``error`` (status=degraded)
- Standalone, no key -> ``conversation.item.created`` (mock)
"""
# In WebRTC mode, events come via WebRTC connection
# This method would handle transcript/tool call events
session = self._sessions.get(session_id)
@@ -386,16 +386,17 @@ function injectStyles() {
}
function checkForChannelsPage(root) {
// Look for a channels page container or dashboard section
const channelsSection = root.querySelector('[data-page="channels"]') ||
root.querySelector('.channels-page') ||
// The dashboard channels view (plugins/views/channels.js) now owns the full
// CRUD UI and mounts into the [data-page="channels"] container. Only take over
// legacy standalone containers that the dashboard view does not manage, so we
// never clobber the Add Channel modal and card actions.
const channelsSection = root.querySelector('.channels-page') ||
root.querySelector('#channels');
if (channelsSection && !channelsSection.hasAttribute('data-aiui-channels')) {
channelsSection.setAttribute('data-aiui-channels', 'true');
fetchChannels().then(renderChannelsUI);
// Set up auto-refresh every 30 seconds
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(async () => {
await fetchChannels();
@@ -1,8 +1,266 @@
/** Channels view — delegates to channels dashboard module. */
/**
* Channels View — Multi-platform messaging channel management with CRUD.
*
* Restores the Add Channel modal, per-platform credential fields, setup hints,
* and card actions (Enable/Disable, Restart, Test, Delete) removed in the
* 3961508 refactor. Aligned with #176 secret policy: credential inputs default
* to env references (env:VAR_NAME), stored secrets are never re-displayed
* (uses *_set flags from the redacted GET), and API 400 validation errors are
* surfaced inline in the modal.
*
* API: /api/channels, /api/channels/platforms, /api/channels/{id}/{toggle,restart,test}
*/
import { showToast, showConfirm } from '../toast.js';
const PLATFORM_META = {
discord: { icon: '🎮', color: '#5865F2', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
slack: { icon: '💬', color: '#4A154B', fields: [
{ key: 'bot_token', label: 'Bot Token', secret: true },
{ key: 'app_token', label: 'App Token', secret: true },
{ key: 'signing_secret', label: 'Signing Secret', secret: true, optional: true },
] },
telegram: { icon: '✈️', color: '#229ED9', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
whatsapp: { icon: '📱', color: '#25D366', fields: [
{ key: 'access_token', label: 'Access Token', secret: true },
{ key: 'phone_number_id', label: 'Phone Number ID' },
{ key: 'verify_token', label: 'Verify Token', secret: true, optional: true },
] },
imessage: { icon: '🍎', color: '#007AFF', fields: [
{ key: 'apple_id', label: 'Apple ID' },
{ key: 'handler_path', label: 'Handler Path' },
] },
signal: { icon: '🔒', color: '#3A76F0', fields: [
{ key: 'phone_number', label: 'Phone Number' },
{ key: 'api_url', label: 'API URL' },
] },
googlechat: { icon: '💼', color: '#00AC47', fields: [
{ key: 'service_account', label: 'Service Account', secret: true },
{ key: 'space_name', label: 'Space Name' },
] },
nostr: { icon: '🟣', color: '#9B59B6', fields: [
{ key: 'private_key', label: 'Private Key (nsec)', secret: true },
{ key: 'relay_url', label: 'Relay URL' },
] },
};
const SETUP_HINTS = {
discord: '1. Create a Bot at discord.com/developers\n2. Enable Message Content Intent\n3. Copy the Bot Token\n4. Invite bot to your server',
slack: '1. Create a Slack App at api.slack.com\n2. Add Bot Token Scopes (chat:write, app_mentions:read)\n3. Enable Socket Mode → copy App Token (xapp-...)\n4. Install to workspace → copy Bot Token (xoxb-...)',
telegram: '1. Message @BotFather on Telegram\n2. Create a new bot with /newbot\n3. Copy the Bot Token',
whatsapp: '1. Create a Meta Business App\n2. Set up WhatsApp Business API\n3. Copy Phone Number ID and Access Token',
};
function metaFor(platform) {
return PLATFORM_META[platform] || { icon: '📡', color: 'var(--db-accent)', fields: [] };
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
export async function render(container) {
container.setAttribute('data-page', 'channels');
const mod = await import('../channels.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let channels = [];
let platforms = [];
try {
const r = await fetch('/api/channels');
const d = await r.json();
channels = d.channels || d || [];
if (!Array.isArray(channels)) {
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
}
} catch (e) {}
try {
const r = await fetch('/api/channels/platforms');
const d = await r.json();
platforms = d.platforms || d || [];
} catch (e) {}
if (!Array.isArray(platforms) || platforms.length === 0) platforms = Object.keys(PLATFORM_META);
const online = channels.filter(c => c.running === true).length;
container.innerHTML = `
<div style="padding:24px;max-width:1200px;margin:0 auto">
<div class="aiui-channels-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div>
<h2 style="margin:0;font-size:1.5rem">Messaging Channels</h2>
<div style="margin-top:4px;font-size:13px;color:var(--db-text-dim)">${online} Online · ${channels.length} Total</div>
</div>
<button id="ch-add" style="padding:8px 18px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:.85rem">+ Add Channel</button>
</div>
<div id="ch-grid" class="db-columns" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"></div>
<div id="ch-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
</div>`;
const grid = container.querySelector('#ch-grid');
if (channels.length === 0) {
grid.innerHTML = `
<div class="aiui-channels-empty" style="grid-column:1/-1;text-align:center;padding:3rem 2rem;color:var(--db-text-dim)">
<div style="font-size:3rem;opacity:.5;margin-bottom:1rem">📡</div>
<h3 style="color:var(--db-text);margin:0 0 .5rem">No Channels Configured</h3>
<p style="margin:.5rem 0">Click <strong>+ Add Channel</strong> to connect Discord, Slack, Telegram, or WhatsApp.</p>
</div>`;
} else {
channels.forEach(ch => {
const platform = (ch.platform || ch.type || ch.id || '').toLowerCase();
const meta = metaFor(platform);
const enabled = ch.enabled !== false;
const isRunning = ch.running === true;
const hasError = !!(ch.start_error && ch.start_error !== '');
const statusLabel = !enabled ? 'Disabled' : isRunning ? 'Connected' : hasError ? 'Error' : 'Stopped';
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'padding:16px;border-radius:12px';
card.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;background:${meta.color}22">${meta.icon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:15px">${esc(ch.name || ch.id || platform)}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:1px;text-transform:capitalize">${esc(platform)}</div>
</div>
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${isRunning ? 'background:rgba(34,197,94,.15);color:#22c55e' : hasError ? 'background:rgba(234,179,8,.15);color:#eab308' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${isRunning ? '●' : hasError ? '⚠' : '○'} ${statusLabel}</span>
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<button class="ch-toggle" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
<button class="ch-restart" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">⟳ Restart</button>
<button class="ch-test" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">🔍 Test</button>
<button class="ch-del" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
</div>`;
grid.appendChild(card);
});
}
container.querySelectorAll('.ch-toggle').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/toggle`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
render(container);
} catch (e) { showToast('Toggle failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-restart').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/restart`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
showToast('Channel restart initiated', 'success');
} catch (e) { showToast('Restart failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-test').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/test`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
const ok = r.ok && d.success !== false;
showToast(ok ? 'Connection test passed!' : 'Test failed: ' + (d.error || `HTTP ${r.status}`), ok ? 'success' : 'error');
if (ok) render(container);
} catch (e) { showToast('Test failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-del').forEach(b => b.addEventListener('click', async () => {
if (!await showConfirm('Delete channel?', 'This action cannot be undone.')) return;
try {
const r = await fetch(`/api/channels/${b.dataset.id}`, { method: 'DELETE' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
render(container);
} catch (e) { showToast('Delete failed: ' + e.message, 'error'); }
}));
container.querySelector('#ch-add')?.addEventListener('click', () => openAddModal(container, platforms));
}
function openAddModal(container, platforms) {
const m = container.querySelector('#ch-modal');
m.style.display = 'flex';
m.innerHTML = `
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:480px;max-width:92vw;max-height:80vh;overflow-y:auto">
<h3 style="margin:0 0 20px;font-size:18px">Add Channel</h3>
<label style="display:block;margin-bottom:14px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Platform</span>
<select id="chf-platform" class="db-form-select">
${platforms.map(p => `<option value="${esc(p)}">${metaFor(p).icon} ${esc(p.charAt(0).toUpperCase() + p.slice(1))}</option>`).join('')}
</select>
</label>
<label style="display:block;margin-bottom:14px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel Name <span style="opacity:.5">(Optional)</span></span>
<input id="chf-name" class="db-form-input" placeholder="my-bot">
</label>
<div id="chf-fields" style="margin-bottom:14px"></div>
<div id="chf-error" style="display:none;margin-bottom:14px;padding:10px 14px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;font-size:12px;color:#ef4444"></div>
<div id="chf-setup-hint" style="margin-bottom:20px;padding:10px 14px;background:rgba(99,102,241,.08);border-radius:8px;font-size:12px;color:var(--db-text-dim)"></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="chf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
<button id="chf-save" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Add Channel</button>
</div>
</div>`;
const selectEl = m.querySelector('#chf-platform');
const fieldsDiv = m.querySelector('#chf-fields');
const hintDiv = m.querySelector('#chf-setup-hint');
const errDiv = m.querySelector('#chf-error');
function updateFields() {
const platform = selectEl.value;
const meta = metaFor(platform);
fieldsDiv.innerHTML = meta.fields.map(f => {
const optTag = f.optional ? ' <span style="opacity:.5">(Optional)</span>' : '';
const placeholder = f.secret ? `env:${platform.toUpperCase()}_${f.key.toUpperCase()}` : f.label;
const helper = f.secret
? '<span style="font-size:11px;color:var(--db-text-dim);display:block;margin-top:3px">Use an env reference — inline secrets are rejected.</span>'
: '';
return `<label style="display:block;margin-bottom:10px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">${esc(f.label)}${optTag}</span>
<input class="chf-field db-form-input" data-field="${esc(f.key)}" data-optional="${f.optional ? '1' : '0'}" type="text" placeholder="${esc(placeholder)}">
${helper}
</label>`;
}).join('');
hintDiv.innerHTML = SETUP_HINTS[platform]
? '<strong>Setup Steps:</strong><pre style="margin:6px 0 0;white-space:pre-wrap;font-family:inherit">' + esc(SETUP_HINTS[platform]) + '</pre>'
: `Configure your ${esc(platform)} integration above.`;
errDiv.style.display = 'none';
}
updateFields();
selectEl.addEventListener('change', updateFields);
m.querySelector('#chf-cancel').addEventListener('click', () => { m.style.display = 'none'; });
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
m.querySelector('#chf-save').addEventListener('click', async () => {
const platform = selectEl.value;
const config = {};
m.querySelectorAll('.chf-field').forEach(f => {
const v = f.value.trim();
if (v) config[f.dataset.field] = v;
});
const body = {
platform,
name: m.querySelector('#chf-name').value.trim() || platform + '-bot',
enabled: true,
config,
};
errDiv.style.display = 'none';
try {
const r = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errDiv.textContent = d.error || d.detail || `Request failed (HTTP ${r.status})`;
errDiv.style.display = 'block';
return;
}
m.style.display = 'none';
showToast('Channel added', 'success');
render(container);
} catch (e) {
errDiv.textContent = e.message;
errDiv.style.display = 'block';
}
});
}
@@ -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
@@ -88,6 +88,7 @@ const FEATURES = [
{ name: "channels", endpoints: [
{ label: "List Channels", method: "GET", url: "/api/channels" },
{ label: "Platforms", method: "GET", url: "/api/channels/platforms" },
{ label: "Add Channel", method: "POST", url: "/api/channels", body: { platform: "telegram", name: "my-bot", enabled: true, config: { bot_token: "env:TELEGRAM_BOT_TOKEN" } } },
]},
{ name: "agents_crud", endpoints: [
{ label: "All Agents", method: "GET", url: "/api/agents" },
+96
View File
@@ -0,0 +1,96 @@
"""Regression tests for issue #189.
The Channels dashboard view lost its CRUD controls in the 3961508 refactor,
leaving a read-only page (list + restart only). These tests assert the
committed frontend view source
``src/praisonaiui/templates/frontend/plugins/views/channels.js`` restores the
Add Channel modal and per-card actions (Enable/Disable, Restart, Test, Delete),
aligned with the #176 secret policy (env references, no plaintext token paste
by default) and the #172 dropdown contrast fix (``.db-form-select``).
"""
from __future__ import annotations
from pathlib import Path
_FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
def _view() -> str:
return CHANNELS_VIEW.read_text()
class TestAddChannelControls:
def test_add_channel_button_present(self):
src = _view()
assert 'id="ch-add"' in src
assert "+ Add Channel" in src
def test_add_modal_has_platform_select(self):
src = _view()
assert 'id="chf-platform"' in src
assert 'id="ch-modal"' in src
def test_platform_select_uses_dashboard_form_class(self):
# #172 dark-theme option contrast fix applies to .db-form-select.
src = _view()
assert "db-form-select" in src
def test_save_posts_to_channels_api(self):
src = _view()
assert "/api/channels" in src
assert "'POST'" in src or '"POST"' in src
assert "JSON.stringify" in src
class TestCardActions:
def test_all_card_actions_present(self):
src = _view()
for cls in ("ch-toggle", "ch-restart", "ch-test", "ch-del"):
assert cls in src, f"missing card action {cls}"
def test_toggle_endpoint(self):
assert "/toggle" in _view()
def test_test_endpoint(self):
assert "/test" in _view()
def test_delete_uses_confirm(self):
src = _view()
assert "showConfirm" in src
assert "DELETE" in src
class TestSecretPolicy:
def test_secret_fields_default_to_env_reference(self):
# Placeholders steer operators to env references per #176.
src = _view()
assert "env:" in src
def test_modal_surfaces_api_validation_error(self):
src = _view()
assert 'id="chf-error"' in src
def test_no_inline_password_prefill(self):
# Fields must not be pre-populated with stored secrets.
src = _view()
assert "value=" not in src or "***REDACTED***" not in src
class TestEmptyState:
def test_empty_state_references_add_button(self):
src = _view()
assert "+ Add Channel" in src
assert "Use the API or CLI to add channels." not in src
class TestExplorerPreset:
def test_explorer_has_add_channel_post(self):
src = EXPLORER_VIEW.read_text()
assert "Add Channel" in src
assert "env:TELEGRAM_BOT_TOKEN" in src
+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
+23 -1
View File
@@ -90,9 +90,10 @@ class TestSessionLifecycle:
assert "openai package not installed" in session_info["error"]
@pytest.mark.asyncio
async def test_session_lifecycle_with_mocked_openai(self):
async def test_session_lifecycle_with_mocked_openai(self, monkeypatch):
"""Test full session lifecycle with mocked OpenAI."""
import sys
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
manager = OpenAIRealtimeManager()
# Mock OpenAI client and response
@@ -140,6 +141,27 @@ class TestSessionLifecycle:
# Verify session is removed from internal tracking
assert session_id not in manager._sessions
@pytest.mark.asyncio
async def test_receive_audio_yields_session_ready_with_api_key(self, monkeypatch):
"""With OPENAI_API_KEY set, receive_audio yields session.ready first."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
manager = OpenAIRealtimeManager()
session_id = "session_ready_test"
manager._sessions[session_id] = {
"status": "created",
"client_secret": "secret_abc",
}
events = []
async for event in manager.receive_audio(session_id):
events.append(event)
break
assert len(events) == 1
assert events[0]["type"] == "session.ready"
assert events[0]["provider"] == "openai"
assert events[0]["client_secret"] == "secret_abc"
@pytest.mark.asyncio
async def test_receive_audio_invalid_session(self):
"""Test receive_audio with invalid session ID."""
+73
View File
@@ -0,0 +1,73 @@
"""Unit tests for `aiui run` propagating --port/--host to gateway main()."""
import os
import textwrap
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from praisonaiui.cli import app
runner = CliRunner()
def _write_gateway_app(tmp_path):
app_file = tmp_path / "app.py"
app_file.write_text(
textwrap.dedent(
"""
from praisonaiui.integration import AIUIGateway
async def main():
pass
"""
),
encoding="utf-8",
)
return app_file
def _invoke(tmp_path):
app_file = _write_gateway_app(tmp_path)
captured = {}
def fake_run(coro):
coro.close()
captured["PORT"] = os.environ.get("PORT")
captured["HOST"] = os.environ.get("HOST")
with patch("socket.socket") as mock_socket:
mock_socket.return_value.__enter__.return_value.bind.return_value = None
with patch(
"praisonaiui.integration.AIUIGateway", return_value=MagicMock()
), patch(
"praisonaiui.integration.check_praisonai_available", return_value=True
), patch("asyncio.run", side_effect=fake_run):
result = runner.invoke(
app,
["run", str(app_file), "--backend", "praisonai", "--port", "8101"],
)
return result, captured
def test_run_sets_port_env_before_gateway_main(tmp_path, monkeypatch):
monkeypatch.delenv("PORT", raising=False)
monkeypatch.delenv("HOST", raising=False)
result, captured = _invoke(tmp_path)
assert result.exit_code == 0, result.stdout
assert captured["PORT"] == "8101"
assert captured["HOST"] == "127.0.0.1"
def test_explicit_port_env_overrides_cli_flag(tmp_path, monkeypatch):
monkeypatch.setenv("PORT", "9000")
monkeypatch.delenv("HOST", raising=False)
result, captured = _invoke(tmp_path)
assert result.exit_code == 0, result.stdout
assert captured["PORT"] == "9000"
assert os.environ["PORT"] == "9000"
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.129"
version = "0.3.132"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },