mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b84b9d3ee7 | ||
|
|
2ebb56f609 | ||
|
|
6725a5bdf7 | ||
|
|
f1f953be76 | ||
|
|
ee994b586d | ||
|
|
44428e5caf | ||
|
|
1748d6d023 | ||
|
|
a45c3af006 | ||
|
|
b54d94dd56 | ||
|
|
d9e8b12628 |
No files matched your search
+1
-1
@@ -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,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.129"
|
||||
__version__ = "0.3.132"
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user