mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.132"
|
||||
version = "0.3.133"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.132"
|
||||
__version__ = "0.3.133"
|
||||
@@ -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,25 @@ 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",
|
||||
"skills.title": "Skills Studio",
|
||||
"skills.subtitle": "Review self_improve proposals",
|
||||
"skills.tab.pending": "Pending",
|
||||
"skills.tab.installed": "Installed",
|
||||
"skills.tab.history": "History",
|
||||
"skills.diff.current": "Current",
|
||||
"skills.diff.proposed": "Proposed",
|
||||
"skills.diff.no_existing": "No existing skill",
|
||||
"skills.action.approve": "Approve",
|
||||
"skills.action.reject": "Reject",
|
||||
"skills.pending.empty": "No pending skill writes",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Settings",
|
||||
@@ -86,6 +105,25 @@ 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",
|
||||
"skills.title": "Estudio de habilidades",
|
||||
"skills.subtitle": "Revisar propuestas de self_improve",
|
||||
"skills.tab.pending": "Pendientes",
|
||||
"skills.tab.installed": "Instaladas",
|
||||
"skills.tab.history": "Historial",
|
||||
"skills.diff.current": "Actual",
|
||||
"skills.diff.proposed": "Propuesto",
|
||||
"skills.diff.no_existing": "No existe la habilidad",
|
||||
"skills.action.approve": "Aprobar",
|
||||
"skills.action.reject": "Rechazar",
|
||||
"skills.pending.empty": "No hay escrituras de habilidades pendientes",
|
||||
"nav.dashboard": "Panel",
|
||||
"nav.agents": "Agentes",
|
||||
"nav.settings": "Configuración",
|
||||
@@ -99,6 +137,25 @@ 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",
|
||||
"skills.title": "Studio de comp\u00e9tences",
|
||||
"skills.subtitle": "Examiner les propositions de self_improve",
|
||||
"skills.tab.pending": "En attente",
|
||||
"skills.tab.installed": "Install\u00e9es",
|
||||
"skills.tab.history": "Historique",
|
||||
"skills.diff.current": "Actuel",
|
||||
"skills.diff.proposed": "Propos\u00e9",
|
||||
"skills.diff.no_existing": "Aucune comp\u00e9tence existante",
|
||||
"skills.action.approve": "Approuver",
|
||||
"skills.action.reject": "Rejeter",
|
||||
"skills.pending.empty": "Aucune \u00e9criture de comp\u00e9tence en attente",
|
||||
"nav.dashboard": "Tableau de bord",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Paramètres",
|
||||
|
||||
@@ -3324,6 +3324,14 @@ def create_app(
|
||||
"description": "Async agent jobs",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"title": "Work Hub",
|
||||
"icon": "🗂️",
|
||||
"group": "Work",
|
||||
"description": "Unified kanban, card detail, chat and job log",
|
||||
"order": 9,
|
||||
},
|
||||
{
|
||||
"id": "jobs-board",
|
||||
"title": "Jobs Board",
|
||||
|
||||
@@ -525,6 +525,7 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
selected,
|
||||
onSelect: opts.interactive !== false ? onSelect : null,
|
||||
onOpen: (card) => {
|
||||
if (typeof opts.onOpen === 'function') { opts.onOpen(card); return; }
|
||||
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
|
||||
openDrawer = openTaskDrawer(card, {
|
||||
apiBase: drawerApiBase,
|
||||
|
||||
@@ -171,6 +171,7 @@ const BUILTIN_VIEWS = {
|
||||
feedback: '/plugins/views/feedback.js',
|
||||
jobs: '/plugins/views/jobs.js',
|
||||
kanban: '/plugins/views/kanban.js',
|
||||
work: '/plugins/views/work-hub.js',
|
||||
'jobs-board': '/plugins/views/jobs-board.js',
|
||||
auth: '/plugins/views/auth.js',
|
||||
api: '/plugins/views/api.js',
|
||||
@@ -1280,7 +1281,316 @@ function initSessionSearch() {
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openSessionSearch = openSessionSearch;
|
||||
|
||||
// ── First-run Onboarding Gate ────────────────────────────────────────
|
||||
// Shown on first visit when running in gateway mode and unauthenticated.
|
||||
// Replaces the raw HTTP 401 experience with a guided token flow.
|
||||
const ONBOARD_SKIP_KEY = 'aiui_onboard_skip';
|
||||
const ONBOARD_TOKEN_KEY = 'aiui_gateway_token';
|
||||
|
||||
function getStoredToken() {
|
||||
try {
|
||||
return window.sessionStorage.getItem(ONBOARD_TOKEN_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the stored gateway token to same-origin requests so that protected
|
||||
// dashboard APIs (/api/pages, /ui-config.json, view modules, …) authenticate
|
||||
// after onboarding. Wrapping window.fetch once keeps every existing caller
|
||||
// unchanged and is a no-op when no token is stored or in standalone mode.
|
||||
let _authFetchInstalled = false;
|
||||
function installAuthFetch() {
|
||||
if (_authFetchInstalled || typeof window.fetch !== 'function') return;
|
||||
_authFetchInstalled = true;
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
|
||||
function isSameOrigin(url) {
|
||||
try {
|
||||
return new URL(url, window.location.href).origin === window.location.origin;
|
||||
} catch (e) {
|
||||
return true; // relative/opaque URLs are same-origin
|
||||
}
|
||||
}
|
||||
|
||||
window.fetch = function (input, init) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return nativeFetch(input, init);
|
||||
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (!isSameOrigin(url)) return nativeFetch(input, init);
|
||||
|
||||
const opts = { ...(init || {}) };
|
||||
const headers = new Headers(
|
||||
opts.headers || (typeof input !== 'string' && input && input.headers) || undefined
|
||||
);
|
||||
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
|
||||
opts.headers = headers;
|
||||
if (!opts.credentials) opts.credentials = 'same-origin';
|
||||
return nativeFetch(input, opts);
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAuthStatus() {
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/auth/status', { headers, credentials: 'same-origin' });
|
||||
if (!res.ok) return { authenticated: false, mode: 'gateway', auth_required: true };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeToken(token) {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (data && data.authenticated) {
|
||||
try { window.sessionStorage.setItem(ONBOARD_TOKEN_KEY, token); } catch (e) { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeShowOnboarding() {
|
||||
let params;
|
||||
try {
|
||||
params = new URLSearchParams(window.location.search);
|
||||
} catch (e) {
|
||||
params = null;
|
||||
}
|
||||
|
||||
// Magic link: ?token= auto-consume, then strip from history.
|
||||
const urlToken = params ? params.get('token') : null;
|
||||
if (urlToken) {
|
||||
const ok = await consumeToken(urlToken);
|
||||
try {
|
||||
params.delete('token');
|
||||
const qs = params.toString();
|
||||
const clean = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (ok) return;
|
||||
}
|
||||
|
||||
const status = await fetchAuthStatus();
|
||||
if (!status) return;
|
||||
|
||||
// Standalone / no-auth / already authenticated → straight to dashboard.
|
||||
if (status.authenticated) return;
|
||||
if (status.mode === 'none' || status.mode === 'standalone') return;
|
||||
if (status.auth_required === false) return;
|
||||
|
||||
// "Don't show again" persisted for this device.
|
||||
try {
|
||||
if (window.localStorage.getItem(ONBOARD_SKIP_KEY) === '1') return;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
await renderOnboarding(status);
|
||||
}
|
||||
|
||||
function renderOnboarding(status) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'aiui-onboarding';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Welcome to PraisonAIUI');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;display:flex;align-items:center;justify-content:center;background:var(--db-bg,#0b0e14);padding:16px';
|
||||
|
||||
const gatewayUrl = (status && status.gateway_url) || window.location.origin;
|
||||
const state = { step: 1 };
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
}
|
||||
|
||||
function card(inner) {
|
||||
return `<div style="background:var(--db-card-bg,#151922);border:1px solid var(--db-border,#2a2f3a);border-radius:12px;padding:28px;max-width:440px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.4)">${inner}</div>`;
|
||||
}
|
||||
|
||||
function steps() {
|
||||
let dots = '';
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const on = i === state.step;
|
||||
dots += `<span aria-current="${on ? 'step' : 'false'}" style="width:8px;height:8px;border-radius:50%;background:${on ? 'var(--db-accent,#4f8cff)' : 'var(--db-border,#2a2f3a)'}"></span>`;
|
||||
}
|
||||
return `<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px">${dots}</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.step === 1) renderWelcome();
|
||||
else if (state.step === 2) renderAuth();
|
||||
else if (state.step === 3) renderProvider();
|
||||
else renderReady();
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:22px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">Welcome to PraisonAIUI</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Confirm your gateway to get started.</p>
|
||||
<label style="display:block;font-size:13px;color:var(--db-text-muted,#9aa4b2);margin-bottom:6px">Gateway URL</label>
|
||||
<input id="ob-url" type="text" value="${gatewayUrl}" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:14px;margin-bottom:6px">
|
||||
<div id="ob-url-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin-bottom:12px"></div>
|
||||
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
|
||||
`);
|
||||
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
|
||||
const url = overlay.querySelector('#ob-url').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-url-err');
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
|
||||
} catch (e) {
|
||||
errEl.textContent = `Cannot reach gateway at ${url}`;
|
||||
return;
|
||||
}
|
||||
state.step = 2;
|
||||
render();
|
||||
});
|
||||
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Authenticate</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Paste your gateway token to connect.</p>
|
||||
<textarea id="ob-token" rows="3" placeholder="gw_xxxx…" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:13px;font-family:monospace;resize:vertical"></textarea>
|
||||
<div id="ob-auth-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin:6px 0 10px"></div>
|
||||
<button id="ob-connect" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Connect</button>
|
||||
<button id="ob-copy-link" style="width:100%;padding:8px;margin-top:10px;border:1px solid var(--db-border,#2a2f3a);background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;border-radius:8px">Copy magic link</button>
|
||||
<button id="ob-back" style="width:100%;padding:8px;margin-top:8px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer">Back</button>
|
||||
`);
|
||||
const connectBtn = overlay.querySelector('#ob-connect');
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-auth-err');
|
||||
errEl.textContent = '';
|
||||
if (!token) { errEl.textContent = 'Enter a token to continue'; return; }
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Connecting…';
|
||||
const ok = await consumeToken(token);
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Connect';
|
||||
if (ok) {
|
||||
state.step = 3;
|
||||
render();
|
||||
} else {
|
||||
errEl.textContent = 'Invalid token — check ~/.praisonai/.env';
|
||||
}
|
||||
});
|
||||
overlay.querySelector('#ob-copy-link').addEventListener('click', () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const link = `${window.location.origin}${window.location.pathname}?token=${encodeURIComponent(token || 'YOUR_TOKEN')}`;
|
||||
try { navigator.clipboard.writeText(link); } catch (e) { /* ignore */ }
|
||||
});
|
||||
overlay.querySelector('#ob-back').addEventListener('click', () => { state.step = 1; render(); });
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Provider setup</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Optional — configure API keys in Settings.</p>
|
||||
<div id="ob-providers" style="margin-bottom:16px;color:var(--db-text-muted,#9aa4b2);font-size:13px">Loading provider status…</div>
|
||||
<button id="ob-provider-next" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-provider-skip" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Skip</button>
|
||||
`);
|
||||
loadProviderStatus();
|
||||
overlay.querySelector('#ob-provider-next').addEventListener('click', () => { state.step = 4; render(); });
|
||||
overlay.querySelector('#ob-provider-skip').addEventListener('click', () => { state.step = 4; render(); });
|
||||
}
|
||||
|
||||
async function loadProviderStatus() {
|
||||
const el = overlay.querySelector('#ob-providers');
|
||||
if (!el) return;
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/config', { headers, credentials: 'same-origin' });
|
||||
const cfg = res.ok ? await res.json() : {};
|
||||
const providers = ['OpenAI', 'Anthropic', 'Google'];
|
||||
el.innerHTML = providers.map((p) => {
|
||||
const key = `${p.toLowerCase()}`;
|
||||
const configured = cfg && (cfg[`${key}_configured`] || (cfg.providers && cfg.providers[key]));
|
||||
const label = configured ? 'configured' : 'missing';
|
||||
const color = configured ? '#4caf50' : 'var(--db-text-muted,#9aa4b2)';
|
||||
return `<div style="display:flex;justify-content:space-between;padding:6px 0"><span style="color:var(--db-text,#e6e9ef)">${p}</span><span style="color:${color}">${label}</span></div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.textContent = 'Provider status unavailable.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReady() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<div style="text-align:center;font-size:40px;margin-bottom:8px">✅</div>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">You're all set</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Gateway connected. Ready to open your dashboard.</p>
|
||||
<button id="ob-open" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Open Dashboard</button>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:14px;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;justify-content:center">
|
||||
<input id="ob-dont-show" type="checkbox"> Don't show again on this device
|
||||
</label>
|
||||
`);
|
||||
overlay.querySelector('#ob-open').addEventListener('click', () => {
|
||||
if (overlay.querySelector('#ob-dont-show').checked) {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
// Focus trap: keep focus within the overlay; ESC disabled on auth step.
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && state.step === 2) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = overlay.querySelectorAll('button, input, textarea, a[href]');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
const firstInput = overlay.querySelector('input, button');
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Attach stored gateway token to same-origin requests (no-op without a token).
|
||||
installAuthFetch();
|
||||
|
||||
// Inject styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = DASHBOARD_STYLE;
|
||||
@@ -1295,6 +1605,13 @@ async function init() {
|
||||
console.warn('[AIUI] Failed to load theme config:', e);
|
||||
}
|
||||
|
||||
// First-run onboarding gate — runs before protected dashboard APIs.
|
||||
try {
|
||||
await maybeShowOnboarding();
|
||||
} catch (e) {
|
||||
console.warn('[AIUI] Onboarding gate error:', e);
|
||||
}
|
||||
|
||||
await buildDashboard();
|
||||
|
||||
// Initialize Ctrl+K / Cmd+K command palette
|
||||
|
||||
@@ -57,9 +57,204 @@ export function searchInput(placeholder = 'Search…', id = 'db-search-input') {
|
||||
return `<input id="${id}" type="search" placeholder="${placeholder}" style="width:100%;max-width:320px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box" />`;
|
||||
}
|
||||
|
||||
/** Escape a string for safe interpolation into template literals. */
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG sparkline (no external library).
|
||||
*
|
||||
* @param {number[]} values - Series values (empty renders a flat baseline).
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.width=96]
|
||||
* @param {number} [opts.height=32]
|
||||
* @param {string} [opts.color] - Stroke colour (defaults to accent token).
|
||||
* @returns {string} SVG markup.
|
||||
*/
|
||||
export function sparklineSVG(values, { width = 96, height = 32, color = 'var(--db-accent,#6366f1)' } = {}) {
|
||||
const data = Array.isArray(values) ? values.filter((v) => typeof v === 'number' && !isNaN(v)) : [];
|
||||
const pad = 2;
|
||||
const w = width - pad * 2;
|
||||
const h = height - pad * 2;
|
||||
if (data.length < 2) {
|
||||
const y = height / 2;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><line x1="${pad}" y1="${y}" x2="${width - pad}" y2="${y}" stroke="var(--db-border,#3f3f46)" stroke-width="1.5" /></svg>`;
|
||||
}
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = pad + (i / (data.length - 1)) * w;
|
||||
const yy = pad + h - ((v - min) / range) * h;
|
||||
return `${x.toFixed(1)},${yy.toFixed(1)}`;
|
||||
});
|
||||
const areaPts = `${pad},${(pad + h).toFixed(1)} ${pts.join(' ')} ${(pad + w).toFixed(1)},${(pad + h).toFixed(1)}`;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon points="${areaPts}" fill="${color}" opacity="0.12" />
|
||||
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric card with value, subtitle, optional delta and sparkline.
|
||||
* Renders a clickable card that navigates via data-nav (handled by caller).
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.title - Card label.
|
||||
* @param {string|number} opts.value - Primary value (already formatted).
|
||||
* @param {string} [opts.subtitle] - Secondary label.
|
||||
* @param {number} [opts.delta] - Signed percentage delta.
|
||||
* @param {number[]} [opts.spark] - Sparkline series.
|
||||
* @param {string} [opts.nav] - Page id to navigate to on click.
|
||||
* @param {string} [opts.accent] - Value colour override.
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
export function metricCard({ title, value, subtitle = '', delta, spark, nav, accent }) {
|
||||
let deltaHtml = '';
|
||||
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
|
||||
const up = delta > 0;
|
||||
const col = up ? '#22c55e' : '#ef4444';
|
||||
const arrow = up ? '▲' : '▼';
|
||||
deltaHtml = `<span style="font-size:11px;color:${col};margin-left:6px">${arrow} ${Math.abs(delta).toFixed(0)}%</span>`;
|
||||
}
|
||||
const sparkHtml = Array.isArray(spark) ? `<div style="margin-top:8px">${sparklineSVG(spark)}</div>` : '';
|
||||
const clickable = nav ? `data-nav="${esc(nav)}" role="button" tabindex="0" style="cursor:pointer"` : '';
|
||||
return `<div class="db-card db-metric-card" ${clickable}>
|
||||
<div class="db-card-title">${esc(title)}</div>
|
||||
<div class="db-card-value"${accent ? ` style="color:${accent}"` : ''}>${esc(value)}${deltaHtml}</div>
|
||||
<div class="db-card-footer">${esc(subtitle)}</div>
|
||||
${sparkHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Relative "time ago" label from an ISO string or epoch (ms or s). */
|
||||
export function timeAgo(input) {
|
||||
if (input == null) return '';
|
||||
let ms;
|
||||
if (typeof input === 'number') {
|
||||
ms = input < 1e12 ? input * 1000 : input;
|
||||
} else {
|
||||
const t = Date.parse(input);
|
||||
if (isNaN(t)) return '';
|
||||
ms = t;
|
||||
}
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return 'now';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Modal shell for view-local dialogs. */
|
||||
export function modalShell(id, innerHtml) {
|
||||
return `<div id="${id}" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center;padding:20px">
|
||||
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;max-width:520px;width:100%;max-height:90vh;overflow:auto;padding:20px">${innerHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-level diff between two text blocks using an LCS table.
|
||||
* Normalises CRLF/CR to LF before comparing. Bounded for large inputs.
|
||||
*
|
||||
* @param {string} before
|
||||
* @param {string} after
|
||||
* @param {number} [maxLines=2000] - Truncate each side to this many lines.
|
||||
* @returns {{type:('add'|'del'|'ctx'), text:string}[]}
|
||||
*/
|
||||
export function diffLines(before, after, maxLines = 2000) {
|
||||
const norm = (s) => String(s == null ? '' : s).replace(/\r\n?/g, '\n');
|
||||
const a = norm(before).split('\n').slice(0, maxLines);
|
||||
const b = norm(after).split('\n').slice(0, maxLines);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ type: 'ctx', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push({ type: 'del', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ type: 'add', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal, XSS-safe markdown preview. All content is escaped first; only a
|
||||
* small, fixed set of inline/block constructs are re-enabled. No raw HTML,
|
||||
* scripts, or attributes from source ever reach the DOM.
|
||||
*
|
||||
* @param {string} md
|
||||
* @returns {string} sanitised HTML string
|
||||
*/
|
||||
export function renderMarkdownPreview(md) {
|
||||
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
||||
const inline = (s) => esc(s)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
const html = [];
|
||||
let inList = false;
|
||||
const closeList = () => { if (inList) { html.push('</ul>'); inList = false; } };
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\t/g, ' ');
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (h) {
|
||||
closeList();
|
||||
const lvl = h[1].length;
|
||||
html.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
|
||||
} else if (li) {
|
||||
if (!inList) { html.push('<ul>'); inList = true; }
|
||||
html.push(`<li>${inline(li[1])}</li>`);
|
||||
} else if (line.trim() === '') {
|
||||
closeList();
|
||||
} else {
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeList();
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a pending approval represents a skill write (rules R1-R5).
|
||||
*
|
||||
* @param {Object} a - approval record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSkillApproval(a) {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const NAMES = ['write_skill', 'skill_write', 'save_skill', 'create_skill'];
|
||||
const tool = String(a.tool_name || '').toLowerCase();
|
||||
const action = String(a.action || '').toLowerCase();
|
||||
if (NAMES.includes(tool) || NAMES.includes(action)) return true;
|
||||
if (String(a.type || '').toLowerCase() === 'skill_write') return true;
|
||||
if (a.metadata && String(a.metadata.kind || '').toLowerCase() === 'skill') return true;
|
||||
const path = String((a.arguments && a.arguments.path) || '');
|
||||
if (path.endsWith('SKILL.md') || path.includes('.praisonai/skills/')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* Approvals View — execution approval queue
|
||||
* API: /api/approvals
|
||||
*/
|
||||
import { isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
@@ -33,11 +35,13 @@ export async function render(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
|
||||
const isSkill = isSkillApproval(a);
|
||||
card.innerHTML = `
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
|
||||
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -777,6 +857,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)
|
||||
@@ -809,6 +898,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;
|
||||
@@ -913,6 +1004,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';
|
||||
@@ -1098,6 +1192,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':
|
||||
@@ -1182,6 +1294,8 @@ function handleWsMessage(data) {
|
||||
setStatus('connected');
|
||||
setStreaming(false);
|
||||
drainQueue();
|
||||
scheduleContextRefresh();
|
||||
refreshMemoryChip();
|
||||
break;
|
||||
|
||||
case 'team_run_error':
|
||||
@@ -1723,6 +1837,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) {
|
||||
@@ -1745,6 +2044,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();
|
||||
}
|
||||
|
||||
@@ -1774,12 +2076,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>`;
|
||||
}
|
||||
@@ -2027,7 +2343,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
|
||||
|
||||
@@ -10,7 +10,11 @@ export async function render(container) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'aiui-board-page-header';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>'
|
||||
+ '<div style="margin:0 0 12px;padding:8px 12px;border:1px solid rgba(99,102,241,.3);'
|
||||
+ 'border-radius:8px;background:rgba(99,102,241,.06);font-size:13px">'
|
||||
+ 'Try the new <a href="/work" style="color:var(--db-accent,#6366f1);font-weight:600">Work Hub</a>'
|
||||
+ ' — kanban, card detail, chat and job logs on one screen.</div>';
|
||||
container.appendChild(header);
|
||||
|
||||
const root = document.createElement('div');
|
||||
|
||||
@@ -1,59 +1,429 @@
|
||||
/**
|
||||
* Overview View — system stats, uptime, feature health
|
||||
* Renders into [data-page="overview"]
|
||||
* Overview View — Agent Command Center (STITCH-001).
|
||||
*
|
||||
* Mission-control home: live metric cards, agent table, needs-attention feed,
|
||||
* activity timeline, and an omnibar that opens the existing command palette.
|
||||
*
|
||||
* Data sources (parallel, fault-tolerant via Promise.allSettled):
|
||||
* /api/agents, /api/sessions, /api/approvals/pending, /api/usage/summary,
|
||||
* /api/usage/timeseries, /api/channels, /api/gateway/status,
|
||||
* /api/features, /api/traces
|
||||
*
|
||||
* Renders into [data-page="overview"].
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let features = [], startTime = Date.now();
|
||||
try {
|
||||
const res = await fetch('/api/features');
|
||||
const data = await res.json();
|
||||
features = data.features || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
import { metricCard, esc, timeAgo } from '/plugins/views/_helpers.js';
|
||||
|
||||
const healthy = features.filter(f => f.health?.status === 'ok').length;
|
||||
const total = features.length;
|
||||
const uptimeMs = Date.now() - (window.__aiuiLoadTime || Date.now());
|
||||
const uptime = uptimeMs < 60000 ? `${Math.round(uptimeMs/1000)}s` : `${Math.round(uptimeMs/60000)}m`;
|
||||
const METRICS_POLL_MS = 30000;
|
||||
const APPROVALS_POLL_MS = 10000;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Status</div>
|
||||
<div class="db-card-value" style="color:#22c55e">● Online</div>
|
||||
<div class="db-card-footer">Uptime: ${uptime}</div>
|
||||
let _metricsTimer = null;
|
||||
let _approvalsTimer = null;
|
||||
let _visibilityHandler = null;
|
||||
let _container = null;
|
||||
|
||||
const STATUS_COLORS = { running: '#22c55e', active: '#22c55e', idle: '#71717a', error: '#ef4444' };
|
||||
|
||||
function navigate(pageId) {
|
||||
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
|
||||
}
|
||||
|
||||
function openPalette() {
|
||||
if (window.aiui?.openCommandPalette) window.aiui.openCommandPalette();
|
||||
else if (window.openCommandPalette) window.openCommandPalette();
|
||||
}
|
||||
|
||||
function chatWithAgent(agentName) {
|
||||
if (agentName) {
|
||||
const url = `/chat?agent=${encodeURIComponent(agentName)}`;
|
||||
history.pushState({ pageId: 'chat' }, '', url);
|
||||
}
|
||||
navigate('chat');
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function settled(results, idx, fallback) {
|
||||
const r = results[idx];
|
||||
return r && r.status === 'fulfilled' ? r.value : fallback;
|
||||
}
|
||||
|
||||
function toArray(data, ...keys) {
|
||||
if (Array.isArray(data)) return data;
|
||||
for (const k of keys) {
|
||||
if (data && Array.isArray(data[k])) return data[k];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function statusDot(status) {
|
||||
const s = (status || 'idle').toLowerCase();
|
||||
const color = STATUS_COLORS[s] || STATUS_COLORS.idle;
|
||||
return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color}" title="${esc(s)}"></span>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/agents'),
|
||||
fetchJson('/api/sessions'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/usage/summary'),
|
||||
fetchJson('/api/usage/timeseries?hours=24'),
|
||||
fetchJson('/api/channels'),
|
||||
fetchJson('/api/gateway/status'),
|
||||
fetchJson('/api/features'),
|
||||
fetchJson('/api/traces?limit=10'),
|
||||
]);
|
||||
|
||||
const agents = toArray(settled(results, 0, {}), 'agents');
|
||||
const sessions = toArray(settled(results, 1, {}), 'sessions');
|
||||
const pending = toArray(settled(results, 2, {}), 'approvals', 'pending');
|
||||
const usage = settled(results, 3, null);
|
||||
const timeseries = settled(results, 4, null);
|
||||
const channels = toArray(settled(results, 5, {}), 'channels');
|
||||
const gateway = settled(results, 6, null);
|
||||
const features = toArray(settled(results, 7, {}), 'features');
|
||||
const traces = toArray(settled(results, 8, {}), 'traces');
|
||||
|
||||
return {
|
||||
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces,
|
||||
errors: {
|
||||
agents: results[0].status === 'rejected',
|
||||
sessions: results[1].status === 'rejected',
|
||||
usage: results[3].status === 'rejected',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function usageSpark(timeseries) {
|
||||
const ts = timeseries && Array.isArray(timeseries.timeseries) ? timeseries.timeseries : [];
|
||||
return ts.map((p) => Number(p.tokens) || 0);
|
||||
}
|
||||
|
||||
function todayTokens(usage) {
|
||||
if (!usage) return null;
|
||||
const s = usage.summary || usage;
|
||||
return s.today_tokens ?? s.total_tokens ?? s.tokens ?? null;
|
||||
}
|
||||
|
||||
function gatewayLabel(gateway) {
|
||||
if (!gateway) return { value: 'Standalone', accent: '#71717a' };
|
||||
if (gateway.status === 'unavailable' || gateway.connected === false) {
|
||||
if (gateway.auth_required || gateway.status === 'auth_required') {
|
||||
return { value: 'Auth required', accent: '#eab308' };
|
||||
}
|
||||
return { value: 'Standalone', accent: '#71717a' };
|
||||
}
|
||||
return { value: 'OK', accent: '#22c55e' };
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
const { agents, sessions, pending, usage, timeseries, channels, gateway, errors } = data;
|
||||
const channelsOnline = channels.filter((c) => c.running === true || c.connected === true).length;
|
||||
const tokens = todayTokens(usage);
|
||||
const gw = gatewayLabel(gateway);
|
||||
|
||||
const cards = [
|
||||
metricCard({
|
||||
title: 'Active Agents', value: agents.length, subtitle: 'registered', nav: 'agents',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Live Sessions', value: errors.sessions ? '—' : sessions.length, subtitle: 'open', nav: 'sessions',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Pending Approvals', value: pending.length, subtitle: 'awaiting action', nav: 'approvals',
|
||||
accent: pending.length > 0 ? '#eab308' : undefined,
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Tokens Today', value: tokens == null ? '—' : Number(tokens).toLocaleString(),
|
||||
subtitle: 'tokens', nav: 'usage', spark: usageSpark(timeseries),
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Channel Health', value: `${channelsOnline}/${channels.length}`, subtitle: 'channels online', nav: 'channels',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Gateway Status', value: gw.value, subtitle: gateway?.runtime_type || 'runtime', nav: 'config', accent: gw.accent,
|
||||
}),
|
||||
];
|
||||
|
||||
return `<div class="db-metric-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px">${cards.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderAgentTable(agents) {
|
||||
if (agents.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">🤖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No agents registered yet</div>
|
||||
<div style="font-size:13px;margin-bottom:1rem">Configure agents to see them here.</div>
|
||||
<button data-nav="agents" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px">Configure agents</button>
|
||||
</div>`;
|
||||
}
|
||||
const rows = agents.slice(0, 20).map((a) => {
|
||||
const id = a.id || a.name || '';
|
||||
const preview = a.instructions || a.description || '';
|
||||
return `<tr data-agent-id="${esc(id)}" class="db-agent-row" style="cursor:pointer;border-top:1px solid var(--db-border)">
|
||||
<td style="padding:10px 12px">${statusDot(a.status)}</td>
|
||||
<td style="padding:10px 12px;font-weight:500">${esc(a.name || id)}</td>
|
||||
<td style="padding:10px 12px"><span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(a.model || '—')}</span></td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px">${esc(timeAgo(a.last_active || a.updated_at) || (a.status || 'idle'))}</td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview.slice(0, 80))}</td>
|
||||
<td style="padding:10px 12px"><button class="db-agent-chat" data-agent-id="${esc(id)}" data-agent-name="${esc(a.name || id)}" style="font-size:11px;padding:4px 12px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:6px;cursor:pointer">Chat</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<div class="db-card" style="padding:0;overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="text-align:left;color:var(--db-text-dim);font-size:11px;text-transform:uppercase">
|
||||
<th style="padding:10px 12px"></th><th style="padding:10px 12px">Name</th><th style="padding:10px 12px">Model</th>
|
||||
<th style="padding:10px 12px">Last active</th><th style="padding:10px 12px">Preview</th><th style="padding:10px 12px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildAttention(data) {
|
||||
const items = [];
|
||||
data.pending.forEach((a) => {
|
||||
items.push({
|
||||
priority: 0, border: '#ef4444',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Approve: ${esc(a.tool_name || a.tool || a.action || 'request')}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(a.agent_name || a.agent || '')}</div></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="atn-approve" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:pointer">Approve</button>
|
||||
<button class="atn-deny" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer">Deny</button>
|
||||
</div>`,
|
||||
});
|
||||
});
|
||||
data.features.filter((f) => f.health && f.health.status !== 'ok').forEach((f) => {
|
||||
items.push({
|
||||
priority: 1, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Feature degraded: ${esc(f.name || 'unknown')}</div></div>
|
||||
<button data-nav="debug" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">View</button>`,
|
||||
});
|
||||
});
|
||||
data.channels.filter((c) => c.enabled !== false && c.running !== true && c.connected !== true).forEach((c) => {
|
||||
items.push({
|
||||
priority: 2, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Channel offline: ${esc(c.name || c.id || c.platform || 'channel')}</div></div>
|
||||
<button data-nav="channels" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Open</button>`,
|
||||
});
|
||||
});
|
||||
if (data.agents.length === 0) {
|
||||
items.push({
|
||||
priority: 4, border: 'var(--db-accent,#6366f1)',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">No agents configured</div></div>
|
||||
<button data-nav="agents" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Configure</button>`,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.priority - b.priority);
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
function renderAttention(data) {
|
||||
const items = buildAttention(data);
|
||||
if (items.length === 0) {
|
||||
return `<div style="padding:20px;color:var(--db-text-dim);font-size:13px;text-align:center">✓ All clear — nothing needs attention</div>`;
|
||||
}
|
||||
return items.map((it) => `<div class="db-card" style="display:flex;align-items:center;gap:10px;padding:12px 14px;margin-bottom:8px;border-left:3px solid ${it.border}">${it.html}</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTimeline(traces) {
|
||||
if (traces.length === 0) {
|
||||
return `<div style="padding:16px;color:var(--db-text-dim);font-size:13px">No recent activity</div>`;
|
||||
}
|
||||
return traces.slice(0, 10).map((t) => {
|
||||
const label = t.name || t.event || t.tool || t.type || 'event';
|
||||
const agent = t.agent_name || t.agent || '';
|
||||
const when = timeAgo(t.timestamp || t.created_at || t.start_time);
|
||||
return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px">
|
||||
<span style="color:var(--db-text-dim);font-size:11px;min-width:64px">${esc(when)}</span>
|
||||
<span style="flex:1">${esc(label)}</span>
|
||||
${agent ? `<span style="color:var(--db-text-dim);font-size:12px">${esc(agent)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const OVERVIEW_STYLE = `<style id="ov-style">
|
||||
@media (max-width: 768px) {
|
||||
.db-metric-grid { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.db-ov-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.db-sparkline polyline, .db-sparkline polygon { transition: none !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
function shell(data) {
|
||||
return `${OVERVIEW_STYLE}
|
||||
<div class="db-omnibar" data-omnibar="1" role="button" tabindex="0" style="display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:18px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:10px;cursor:pointer;color:var(--db-text-dim);max-width:520px">
|
||||
<span>🔍</span><span style="flex:1;font-size:13px">Search pages, agents, sessions…</span>
|
||||
<span style="font-size:11px;padding:2px 8px;border:1px solid var(--db-border);border-radius:6px">Ctrl+K</span>
|
||||
</div>
|
||||
|
||||
<div id="ov-metrics">${renderMetrics(data)}</div>
|
||||
|
||||
<div class="db-ov-columns" style="display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-top:20px">
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Active Agents</h3>
|
||||
<div id="ov-agents">${renderAgentTable(data.agents)}</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Features</div>
|
||||
<div class="db-card-value">${total}</div>
|
||||
<div class="db-card-footer">${healthy} active</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Health</div>
|
||||
<div class="db-card-value">${total > 0 ? Math.round(healthy/total*100) : 0}%</div>
|
||||
<div class="db-card-footer">${total - healthy} degraded</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">API Latency</div>
|
||||
<div class="db-card-value">${Date.now() - startTime}ms</div>
|
||||
<div class="db-card-footer">/api/features</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Needs Attention</h3>
|
||||
<div id="ov-attention">${renderAttention(data)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:28px 0 16px;font-size:15px;font-weight:600">Feature Health</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">
|
||||
${features.map(f => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${f.name || 'unknown'}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${f.description || ''}</div>
|
||||
</div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<h3 style="margin:24px 0 12px;font-size:15px;font-weight:600">Recent Activity</h3>
|
||||
<div id="ov-timeline" class="db-card" style="padding:6px 16px">${renderTimeline(data.traces)}</div>
|
||||
|
||||
<details style="margin-top:24px">
|
||||
<summary style="cursor:pointer;font-size:15px;font-weight:600;margin-bottom:12px">Feature Health</summary>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:12px">
|
||||
${data.features.map((f) => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div><div style="font-size:13px;font-weight:500">${esc(f.name || 'unknown')}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(f.description || '')}</div></div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="ov-drawer"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openDrawer(agent) {
|
||||
const host = _container?.querySelector('#ov-drawer');
|
||||
if (!host) return;
|
||||
const id = agent.id || agent.name || '';
|
||||
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
||||
host.innerHTML = `
|
||||
<div class="ov-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200">
|
||||
<div style="position:absolute;top:0;right:0;height:100%;width:400px;max-width:92vw;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);padding:24px;overflow-y:auto;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:18px">${esc(agent.name || id)}</h2>
|
||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(agent.model || '—')}</span>
|
||||
${statusDot(agent.status)}<span style="font-size:12px;color:var(--db-text-dim)">${esc(agent.status || 'idle')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ov-drawer-close" style="background:none;border:none;color:var(--db-text-dim);font-size:20px;cursor:pointer">×</button>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Instructions</div>
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--db-text)">${esc((agent.instructions || agent.description || '—').slice(0, 400))}</div>
|
||||
</div>
|
||||
${tools.length ? `<div style="margin-top:18px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Tools</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">${tools.map((t) => `<span style="font-size:11px;padding:3px 9px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(typeof t === 'string' ? t : t.name || 'tool')}</span>`).join('')}</div>
|
||||
</div>` : ''}
|
||||
<div style="margin-top:24px;display:flex;gap:10px">
|
||||
<button class="ov-drawer-chat" data-agent-name="${esc(agent.name || id)}" style="flex:1;padding:9px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Chat now</button>
|
||||
<button class="ov-drawer-traces" style="padding:9px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">View traces</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => { host.innerHTML = ''; };
|
||||
host.querySelector('.ov-drawer-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ov-drawer-close').addEventListener('click', close);
|
||||
host.querySelector('.ov-drawer-chat')?.addEventListener('click', (e) => {
|
||||
close();
|
||||
chatWithAgent(e.currentTarget.dataset.agentName);
|
||||
});
|
||||
host.querySelector('.ov-drawer-traces')?.addEventListener('click', () => { close(); navigate('traces'); });
|
||||
}
|
||||
|
||||
function bindEvents(container, data) {
|
||||
const agentById = (id) => data.agents.find((a) => (a.id || a.name) === id);
|
||||
|
||||
container.querySelectorAll('[data-nav]').forEach((el) => {
|
||||
const go = () => navigate(el.getAttribute('data-nav'));
|
||||
el.addEventListener('click', go);
|
||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
});
|
||||
|
||||
container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
|
||||
|
||||
container.querySelectorAll('.db-agent-row').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.db-agent-chat')) return;
|
||||
const a = agentById(row.dataset.agentId);
|
||||
if (a) openDrawer(a);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.db-agent-chat').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
chatWithAgent(btn.dataset.agentName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.atn-approve').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/approve`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.atn-deny').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/deny`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
_container.__ovData = data;
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
if (!_container || document.hidden) return;
|
||||
let pending = [];
|
||||
try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
|
||||
const data = _container.__ovData;
|
||||
if (!data) return;
|
||||
data.pending = pending;
|
||||
const metricsEl = _container.querySelector('#ov-metrics');
|
||||
const attnEl = _container.querySelector('#ov-attention');
|
||||
if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
|
||||
if (attnEl) attnEl.innerHTML = renderAttention(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
_metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
|
||||
_approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
|
||||
_visibilityHandler = () => { if (!document.hidden) refreshApprovals(); };
|
||||
document.addEventListener('visibilitychange', _visibilityHandler);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_metricsTimer) { clearInterval(_metricsTimer); _metricsTimer = null; }
|
||||
if (_approvalsTimer) { clearInterval(_approvalsTimer); _approvalsTimer = null; }
|
||||
if (_visibilityHandler) { document.removeEventListener('visibilitychange', _visibilityHandler); _visibilityHandler = null; }
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
stopPolling();
|
||||
_container = null;
|
||||
}
|
||||
@@ -1,72 +1,354 @@
|
||||
/**
|
||||
* Skills View — tool/skill catalog with enable/disable toggles
|
||||
* API: /api/skills
|
||||
* Skills Studio (STITCH-008) — unified Pending → Diff → Approve → Test →
|
||||
* Installed lifecycle for self_improve skill writes.
|
||||
*
|
||||
* Tabs: Pending (skill-filtered approvals) · Installed (toggle catalog) · History.
|
||||
* URL state: /skills?tab=pending|installed|history&approval_id={id}
|
||||
*
|
||||
* Reuses existing /api/approvals and /api/skills endpoints; no new backend.
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
import { esc, timeAgo, diffLines, renderMarkdownPreview, isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
let tools = [], categories = [];
|
||||
try { const r = await fetch('/api/skills'); const d = await r.json(); tools = d.tools || d.skills || d || []; if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id,t]) => ({id,...(typeof t==='object'?t:{name:t})})); } catch(e) {}
|
||||
try { const r = await fetch('/api/skills/categories'); const d = await r.json(); categories = d.categories || d || []; } catch(e) {}
|
||||
const RISK_ICONS = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
|
||||
|
||||
// Group by category
|
||||
const grouped = {};
|
||||
tools.forEach(t => { const cat = t.category || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(t); });
|
||||
let _container = null;
|
||||
|
||||
const enabledCount = tools.filter(t => t.enabled !== false).length;
|
||||
function currentTab() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
return p.get('tab') || '';
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card"><div class="db-card-title">Total Skills</div><div class="db-card-value">${tools.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${Object.keys(grouped).length}</div></div>
|
||||
function currentApprovalId() {
|
||||
return new URLSearchParams(location.search).get('approval_id') || '';
|
||||
}
|
||||
|
||||
function setUrlState(tab, approvalId) {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (tab) p.set('tab', tab); else p.delete('tab');
|
||||
if (approvalId) p.set('approval_id', approvalId); else p.delete('approval_id');
|
||||
history.replaceState(history.state, '', `${location.pathname}?${p.toString()}`);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function approvalId(a) {
|
||||
return a.id || a.approval_id || '';
|
||||
}
|
||||
|
||||
function skillName(a) {
|
||||
const args = a.arguments || {};
|
||||
if (args.skill_name) return args.skill_name;
|
||||
if (args.name) return args.name;
|
||||
const path = String(args.path || '');
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (path.endsWith('SKILL.md') && parts.length >= 2) return parts[parts.length - 2];
|
||||
return a.tool_name || a.action || 'skill';
|
||||
}
|
||||
|
||||
function proposedContent(a) {
|
||||
const args = a.arguments || {};
|
||||
return a.proposed_content || args.content || args.skill_md || '';
|
||||
}
|
||||
|
||||
function changeType(a) {
|
||||
if (a.current_content) return 'Update';
|
||||
const args = a.arguments || {};
|
||||
if (String(args.op || a.type || '').toLowerCase().includes('delete')) return 'Delete';
|
||||
return 'New';
|
||||
}
|
||||
|
||||
// ── Diff overlay ────────────────────────────────────────────────────
|
||||
|
||||
function renderDiffRows(before, after) {
|
||||
const rows = diffLines(before, after);
|
||||
const colorFor = { add: 'rgba(34,197,94,.12)', del: 'rgba(239,68,68,.12)', ctx: 'transparent' };
|
||||
const signFor = { add: '+', del: '-', ctx: ' ' };
|
||||
let adds = 0;
|
||||
let dels = 0;
|
||||
const body = rows.map((r) => {
|
||||
if (r.type === 'add') adds++;
|
||||
if (r.type === 'del') dels++;
|
||||
return `<div style="display:flex;font-family:monospace;font-size:12px;background:${colorFor[r.type]}">
|
||||
<span style="width:16px;text-align:center;color:var(--db-text-dim);flex-shrink:0">${signFor[r.type]}</span>
|
||||
<span style="white-space:pre-wrap;word-break:break-word">${esc(r.text)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return { html: body, adds, dels };
|
||||
}
|
||||
|
||||
function diffOverlayHtml(a, skills) {
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
const hasProposed = after !== '';
|
||||
const { html: rawRows, adds, dels } = renderDiffRows(before, after);
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const leftEmpty = before === '';
|
||||
return `
|
||||
<div class="ss-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;display:flex;justify-content:flex-end">
|
||||
<div class="ss-panel" style="width:60%;max-width:920px;height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);display:flex;flex-direction:column;box-sizing:border-box">
|
||||
<div style="padding:16px 20px;border-bottom:1px solid var(--db-border);display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600">${esc(name)}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">${RISK_ICONS[a.risk_level] || ''} ${esc(a.agent_name || '')} · +${adds} −${dels}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="ss-mode" data-mode="raw" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:var(--db-accent);color:#fff;border-radius:6px;cursor:pointer">Raw</button>
|
||||
<button class="ss-mode" data-mode="preview" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Preview</button>
|
||||
<button class="ss-close" style="background:none;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasProposed ? '' : '<div style="padding:12px 20px;background:rgba(239,68,68,.1);color:#ef4444;font-size:13px">Content unavailable — approval disabled</div>'}
|
||||
<div class="ss-diff-body" style="flex:1;overflow:auto;padding:16px 20px">
|
||||
<div class="ss-columns" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Current</div>
|
||||
<div class="ss-left">${leftEmpty ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Proposed</div>
|
||||
<div class="ss-right">${rawRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid var(--db-border);display:flex;gap:10px;justify-content:flex-end">
|
||||
<button class="ss-deny" data-id="${esc(approvalId(a))}" style="padding:8px 18px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer;font-size:13px">Reject</button>
|
||||
<button class="ss-approve" data-id="${esc(approvalId(a))}" ${hasProposed ? '' : 'disabled'} style="padding:8px 18px;background:${hasProposed ? 'rgba(34,197,94,.15)' : 'var(--db-card-bg)'};color:${hasProposed ? '#22c55e' : 'var(--db-text-dim)'};border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:${hasProposed ? 'pointer' : 'not-allowed'};font-size:13px">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openDiff(a, skills) {
|
||||
const host = _container.querySelector('#ss-overlay-host');
|
||||
if (!host) return;
|
||||
host.innerHTML = diffOverlayHtml(a, skills);
|
||||
setUrlState('pending', approvalId(a));
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
|
||||
const close = () => { host.innerHTML = ''; setUrlState('pending', ''); };
|
||||
host.querySelector('.ss-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ss-close').addEventListener('click', close);
|
||||
|
||||
host.querySelectorAll('.ss-mode').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.mode;
|
||||
host.querySelectorAll('.ss-mode').forEach((b) => {
|
||||
const on = b === btn;
|
||||
b.style.background = on ? 'var(--db-accent)' : 'transparent';
|
||||
b.style.color = on ? '#fff' : 'var(--db-text)';
|
||||
});
|
||||
const left = host.querySelector('.ss-left');
|
||||
const right = host.querySelector('.ss-right');
|
||||
if (mode === 'preview') {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : renderMarkdownPreview(before);
|
||||
right.innerHTML = renderMarkdownPreview(after);
|
||||
} else {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`;
|
||||
right.innerHTML = renderDiffRows(before, after).html;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const resolve = async (verb, id) => {
|
||||
try { await fetch(`/api/approvals/${id}/${verb}`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
paint();
|
||||
};
|
||||
host.querySelector('.ss-approve')?.addEventListener('click', (e) => {
|
||||
if (e.currentTarget.disabled) return;
|
||||
resolve('approve', e.currentTarget.dataset.id);
|
||||
});
|
||||
host.querySelector('.ss-deny')?.addEventListener('click', (e) => resolve('deny', e.currentTarget.dataset.id));
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
function pendingCard(a, skills) {
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const preview = proposedContent(a).slice(0, 200);
|
||||
return `<div class="ss-pending-card db-card" data-id="${esc(approvalId(a))}" style="margin-bottom:10px;padding:16px 20px;cursor:pointer">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:14px">${esc(name.slice(0, 40))}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">🤖 ${esc(a.agent_name || '—')} · ${esc((a.metadata && a.metadata.source) || 'self_improve')} · ${esc(timeAgo(a.created_at))}</div>
|
||||
</div>
|
||||
<div style="font-size:12px">${RISK_ICONS[a.risk_level] || ''}</div>
|
||||
</div>
|
||||
<div id="skills-list"></div>
|
||||
`;
|
||||
${preview ? `<pre style="margin:10px 0 0;font-size:11px;color:var(--db-text-dim);white-space:pre-wrap;max-height:44px;overflow:hidden">${esc(preview)}</pre>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const list = container.querySelector('#skills-list');
|
||||
for (const [category, catTools] of Object.entries(grouped)) {
|
||||
const section = document.createElement('div');
|
||||
section.style.marginBottom = '20px';
|
||||
section.innerHTML = `<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${category} (${catTools.length})</h3>`;
|
||||
function pendingTab(pending, skills) {
|
||||
if (pending.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">📖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No pending skill writes</div>
|
||||
<div style="font-size:13px">Set <code>SKILL_WRITE_APPROVAL=1</code> and run an agent with <code>self_improve</code> to stage proposals here.</div>
|
||||
</div>`;
|
||||
}
|
||||
return pending.map((a) => pendingCard(a, skills)).join('');
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'db-columns';
|
||||
grid.style.gridTemplateColumns = 'repeat(auto-fill,minmax(280px,1fr))';
|
||||
|
||||
catTools.forEach(t => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'padding:14px 18px;display:flex;align-items:center;justify-content:space-between';
|
||||
function installedTab(tools, pendingNames) {
|
||||
const grouped = {};
|
||||
tools.forEach((t) => { const c = t.category || 'other'; (grouped[c] = grouped[c] || []).push(t); });
|
||||
if (tools.length === 0) return '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
return Object.entries(grouped).map(([category, catTools]) => {
|
||||
const cards = catTools.map((t) => {
|
||||
const enabled = t.enabled !== false;
|
||||
const icon = t.icon || '🔧';
|
||||
const keyStatus = t.api_key_set ? '🔑' : '';
|
||||
card.innerHTML = `
|
||||
const hasPending = pendingNames.has(t.name || t.id);
|
||||
return `<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:1">
|
||||
<span style="font-size:20px">${icon}</span>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${t.name || t.id} ${keyStatus}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${t.description || ''}</div>
|
||||
<div style="font-size:13px;font-weight:500">${esc(t.name || t.id)} ${keyStatus}${hasPending ? ' <span title="Pending change" style="color:#eab308">●</span>' : ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(t.description || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label style="position:relative;display:inline-block;width:40px;height:22px;cursor:pointer">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${t.id || t.name}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${esc(t.id || t.name)}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<span style="position:absolute;inset:0;background:${enabled ? 'var(--db-accent)' : 'var(--db-border)'};border-radius:22px;transition:0.3s"></span>
|
||||
<span style="position:absolute;top:2px;left:${enabled ? '20px' : '2px'};width:18px;height:18px;background:#fff;border-radius:50%;transition:0.3s"></span>
|
||||
</label>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin-bottom:20px">
|
||||
<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${esc(category)} (${catTools.length})</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">${cards}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
list.appendChild(section);
|
||||
function historyTab(history) {
|
||||
const skillHist = (Array.isArray(history) ? history : []).filter(isSkillApproval);
|
||||
if (skillHist.length === 0) return '<div style="font-size:13px;color:var(--db-text-dim)">No skill approval history</div>';
|
||||
return skillHist.slice(0, 25).map((h) => {
|
||||
const approved = h.status === 'approved' || h.approved;
|
||||
return `<div style="padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between;gap:10px">
|
||||
<span>${esc(skillName(h))} — ${esc(h.agent_name || '')}</span>
|
||||
<span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'} · ${esc(timeAgo(h.resolved_at || h.created_at))}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const STUDIO_STYLE = `<style id="ss-style">
|
||||
@media (max-width: 768px) {
|
||||
.ss-panel { width: 100% !important; }
|
||||
.ss-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
if (tools.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
function shell(data) {
|
||||
const { pending, tools } = data;
|
||||
const tab = data.activeTab;
|
||||
const enabledCount = tools.filter((t) => t.enabled !== false).length;
|
||||
const catCount = new Set(tools.map((t) => t.category || 'other')).size;
|
||||
const tabBtn = (id, label, badge) => {
|
||||
const on = tab === id;
|
||||
return `<button class="ss-tab" data-tab="${id}" style="padding:8px 16px;border:none;border-bottom:2px solid ${on ? 'var(--db-accent)' : 'transparent'};background:none;color:${on ? 'var(--db-text)' : 'var(--db-text-dim)'};font-size:13px;font-weight:${on ? '600' : '400'};cursor:pointer">${label}${badge ? ` <span style="background:rgba(234,179,8,.2);color:#eab308;padding:1px 7px;border-radius:10px;font-size:11px;margin-left:4px">${badge}</span>` : ''}</button>`;
|
||||
};
|
||||
let body = '';
|
||||
if (tab === 'installed') body = installedTab(tools, data.pendingNames);
|
||||
else if (tab === 'history') body = historyTab(data.history);
|
||||
else body = pendingTab(pending, tools);
|
||||
|
||||
container.querySelectorAll('.skill-toggle').forEach(cb => {
|
||||
return `${STUDIO_STYLE}
|
||||
<div style="margin-bottom:8px">
|
||||
<h2 style="margin:0;font-size:1.1rem;font-weight:600">Skills Studio</h2>
|
||||
<div style="font-size:13px;color:var(--db-text-dim)">Review self_improve proposals</div>
|
||||
</div>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin:16px 0 20px">
|
||||
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${catCount}</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;border-bottom:1px solid var(--db-border);margin-bottom:16px">
|
||||
${tabBtn('pending', 'Pending', pending.length || '')}
|
||||
${tabBtn('installed', 'Installed', '')}
|
||||
${tabBtn('history', 'History', '')}
|
||||
</div>
|
||||
<div id="ss-body">${body}</div>
|
||||
<div id="ss-overlay-host"></div>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/skills'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/approvals/history'),
|
||||
]);
|
||||
const val = (i, f) => (results[i].status === 'fulfilled' ? results[i].value : f);
|
||||
const sk = val(0, {});
|
||||
let tools = sk.tools || sk.skills || sk || [];
|
||||
if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id, t]) => ({ id, ...(typeof t === 'object' ? t : { name: t }) }));
|
||||
const rawPending = (val(1, {}).approvals) || [];
|
||||
const history = (val(2, {}).approvals) || (val(2, {}).history) || [];
|
||||
const pending = rawPending.filter(isSkillApproval);
|
||||
const pendingNames = new Set(pending.map((a) => skillName(a)));
|
||||
return { tools, pending, history, pendingNames };
|
||||
}
|
||||
|
||||
function bindEvents(data) {
|
||||
_container.querySelectorAll('.ss-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
data.activeTab = btn.dataset.tab;
|
||||
setUrlState(data.activeTab, '');
|
||||
renderShell(data);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.ss-pending-card').forEach((card) => {
|
||||
card.addEventListener('click', () => {
|
||||
const a = data.pending.find((x) => approvalId(x) === card.dataset.id);
|
||||
if (a) openDiff(a, data.tools);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.skill-toggle').forEach((cb) => {
|
||||
cb.addEventListener('change', async () => {
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e) {}
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
paint();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(data) {
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(data);
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
const tab = currentTab() || (data.pending.length > 0 ? 'pending' : 'installed');
|
||||
data.activeTab = tab;
|
||||
renderShell(data);
|
||||
const deepId = currentApprovalId();
|
||||
if (deepId && tab === 'pending') {
|
||||
const a = data.pending.find((x) => approvalId(x) === deepId);
|
||||
if (a) openDiff(a, data.tools);
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
_container = null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Unified Work Hub (/work) — three-pane operator surface (STITCH-003).
|
||||
*
|
||||
* Pane 1: Kanban board (reuses createInteractiveBoard from board.js).
|
||||
* Pane 2: Selected card detail with Description / Chat / Tools / Traces tabs.
|
||||
* Pane 3: Activity — job runs, live log, trace link for the card's latest run.
|
||||
*
|
||||
* Card selection updates panes 2+3 without a page navigation. URL state
|
||||
* (?board=&card=&tab=) is kept in sync for deep links.
|
||||
*/
|
||||
import { createInteractiveBoard, relativeTime, escapeHtml } from '../board.js';
|
||||
|
||||
const STYLE_ID = 'work-hub-styles';
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.work-hub { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
|
||||
.work-hub-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; }
|
||||
.work-hub-p1 { width:300px; min-width:240px; flex:0 0 auto; }
|
||||
.work-hub-p2 { flex:1 1 auto; min-width:0; }
|
||||
.work-hub-p3 { width:320px; min-width:280px; flex:0 0 auto; }
|
||||
.work-hub-pane-head { padding:10px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
|
||||
font-size:13px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
|
||||
.work-hub-empty { padding:24px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
|
||||
.work-hub-tabs { display:flex; gap:4px; padding:8px 14px 0; border-bottom:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-tab { padding:6px 12px; font-size:13px; cursor:pointer; border:none; background:none;
|
||||
color:var(--db-text-dim,#a1a1aa); border-bottom:2px solid transparent; }
|
||||
.work-hub-tab.active { color:var(--db-text,#e4e4e7); border-bottom-color:var(--db-accent,#6366f1); }
|
||||
.work-hub-tab:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.work-hub-tabbody { padding:14px; }
|
||||
.work-hub-chat-log { display:flex; flex-direction:column; gap:8px; margin-bottom:12px; }
|
||||
.work-hub-chat-msg { padding:8px 10px; border-radius:8px; font-size:13px; line-height:1.5;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-chat-msg .role { font-size:11px; font-weight:600; color:var(--db-accent,#6366f1); }
|
||||
.work-hub-composer { display:flex; gap:8px; align-items:flex-end; }
|
||||
.work-hub-composer textarea { flex:1; resize:none; min-height:40px; max-height:64px; padding:8px 10px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); border-radius:8px;
|
||||
color:var(--db-text,#e4e4e7); font-size:13px; box-sizing:border-box; }
|
||||
.work-hub-log { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
|
||||
line-height:1.6; white-space:pre-wrap; background:var(--db-card-bg,#27272a);
|
||||
border:1px solid var(--db-border,#3f3f46); border-radius:8px; padding:10px; max-height:40vh; overflow:auto; }
|
||||
.work-hub-job { display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; }
|
||||
.work-hub-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
|
||||
@media (max-width:1024px) {
|
||||
.work-hub { flex-direction:column; }
|
||||
.work-hub-p1, .work-hub-p2, .work-hub-p3 { width:auto; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
if (s === 'done' || s === 'completed' || s === 'success') return '#22c55e';
|
||||
if (s === 'running' || s === 'in_progress') return '#f59e0b';
|
||||
if (s === 'failed' || s === 'error' || s === 'blocked') return '#ef4444';
|
||||
return '#6366f1';
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'work');
|
||||
container.classList.add('work-hub-page');
|
||||
container.innerHTML = '';
|
||||
injectStyles();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const boardState = { id: params.get('board') || 'default' };
|
||||
let selectedCard = null;
|
||||
let activeTab = params.get('tab') || 'description';
|
||||
const pendingCardId = params.get('card');
|
||||
|
||||
const hub = document.createElement('div');
|
||||
hub.className = 'work-hub';
|
||||
container.appendChild(hub);
|
||||
|
||||
const p1 = document.createElement('div');
|
||||
p1.className = 'work-hub-pane work-hub-p1';
|
||||
const p1Body = document.createElement('div');
|
||||
p1.appendChild(p1Body);
|
||||
hub.appendChild(p1);
|
||||
|
||||
const p2 = document.createElement('div');
|
||||
p2.className = 'work-hub-pane work-hub-p2';
|
||||
hub.appendChild(p2);
|
||||
|
||||
const p3 = document.createElement('div');
|
||||
p3.className = 'work-hub-pane work-hub-p3';
|
||||
hub.appendChild(p3);
|
||||
|
||||
function syncUrl() {
|
||||
const q = new URLSearchParams();
|
||||
q.set('board', boardState.id);
|
||||
if (selectedCard) { q.set('card', selectedCard.id); q.set('tab', activeTab); }
|
||||
const url = `${window.location.pathname}?${q.toString()}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!selectedCard) {
|
||||
p2.innerHTML = '<div class="work-hub-empty">Select a card to see its details.</div>';
|
||||
return;
|
||||
}
|
||||
const c = selectedCard;
|
||||
const meta = c.meta || {};
|
||||
const hasAgent = !!(c.assignee || meta.agent_id);
|
||||
const tabs = [
|
||||
['description', 'Description', false],
|
||||
['chat', 'Chat', !hasAgent],
|
||||
['tools', 'Tools', false],
|
||||
['traces', 'Traces', false],
|
||||
];
|
||||
p2.innerHTML = `
|
||||
<div class="work-hub-pane-head">${escapeHtml(c.title || 'Card')}
|
||||
<span class="db-text-dim" style="font-weight:400;font-size:12px;margin-left:8px">${escapeHtml(c.status || '')}</span>
|
||||
</div>
|
||||
<div class="work-hub-tabs">
|
||||
${tabs.map(([id, label, disabled]) =>
|
||||
`<button class="work-hub-tab ${id === activeTab ? 'active' : ''}" data-tab="${id}" ${disabled ? 'disabled' : ''}>${label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="work-hub-tabbody" id="work-hub-tabbody"></div>`;
|
||||
|
||||
p2.querySelectorAll('.work-hub-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return;
|
||||
activeTab = btn.getAttribute('data-tab');
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
});
|
||||
});
|
||||
|
||||
const body = p2.querySelector('#work-hub-tabbody');
|
||||
if (activeTab === 'description') {
|
||||
body.innerHTML = c.body
|
||||
? `<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${escapeHtml(c.body)}</div>`
|
||||
: '<div class="work-hub-empty">No description.</div>';
|
||||
} else if (activeTab === 'chat') {
|
||||
renderChat(body, c);
|
||||
} else if (activeTab === 'tools') {
|
||||
const tools = meta.tools || [];
|
||||
body.innerHTML = tools.length
|
||||
? tools.map((t) => `<span class="db-badge" style="display:inline-block;margin:2px;padding:2px 8px;border-radius:6px;background:var(--db-card-bg,#27272a);font-size:12px">${escapeHtml(String(t))}</span>`).join('')
|
||||
: '<div class="work-hub-empty">No tools listed for this card.</div>';
|
||||
} else if (activeTab === 'traces') {
|
||||
const sessionId = meta.session_id;
|
||||
body.innerHTML = sessionId
|
||||
? `<a href="/traces?session_id=${encodeURIComponent(sessionId)}" style="color:var(--db-accent,#6366f1)">Open traces for this session →</a>`
|
||||
: '<div class="work-hub-empty">No traces linked.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChat(body, card) {
|
||||
const agentId = card.assignee || (card.meta || {}).agent_id || '';
|
||||
body.innerHTML = `
|
||||
<div class="work-hub-chat-log" id="work-hub-chat-log">
|
||||
<div class="work-hub-empty">Ask the assigned agent (${escapeHtml(agentId)}) about this card.</div>
|
||||
</div>
|
||||
<div class="work-hub-composer">
|
||||
<textarea id="work-hub-composer" rows="2" placeholder="Message ${escapeHtml(agentId)}…"></textarea>
|
||||
<button class="db-btn db-btn-sm" id="work-hub-send">Send</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px"><a href="/chat" style="color:var(--db-accent,#6366f1)">Open full chat →</a></div>`;
|
||||
|
||||
const log = body.querySelector('#work-hub-chat-log');
|
||||
const ta = body.querySelector('#work-hub-composer');
|
||||
const send = body.querySelector('#work-hub-send');
|
||||
send.addEventListener('click', () => {
|
||||
const text = ta.value.trim();
|
||||
if (!text) return;
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'work-hub-chat-msg';
|
||||
msg.innerHTML = `<div class="role">You</div>${escapeHtml(text)}`;
|
||||
if (log.querySelector('.work-hub-empty')) log.innerHTML = '';
|
||||
log.appendChild(msg);
|
||||
ta.value = '';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderActivity() {
|
||||
if (!selectedCard) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No card selected.</div>';
|
||||
return;
|
||||
}
|
||||
const meta = selectedCard.meta || {};
|
||||
const jobId = meta.job_id;
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Loading…</div>';
|
||||
if (!jobId) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No jobs linked to this card.</div>';
|
||||
return;
|
||||
}
|
||||
let job = null;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`);
|
||||
if (res.ok) job = await res.json();
|
||||
} catch (_) { /* offline */ }
|
||||
if (!job) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Could not load job.</div>';
|
||||
return;
|
||||
}
|
||||
const logLines = (job.log || job.output || '').split('\n').slice(-50).join('\n');
|
||||
p3.innerHTML = `
|
||||
<div class="work-hub-pane-head">Activity</div>
|
||||
<div style="padding:14px">
|
||||
<div class="work-hub-job">
|
||||
<span class="work-hub-dot" style="background:${statusColor(job.status)}"></span>
|
||||
<span>${escapeHtml(job.id || jobId)}</span>
|
||||
<span class="db-text-dim" style="margin-left:auto;font-size:12px">${escapeHtml(job.status || '')}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim,#a1a1aa);margin:4px 0 10px">${job.updated_at ? relativeTime(job.updated_at) : ''}</div>
|
||||
<div class="work-hub-log">${escapeHtml(logLines) || 'No log output.'}</div>
|
||||
${meta.session_id ? `<div style="margin-top:10px"><a href="/traces?session_id=${encodeURIComponent(meta.session_id)}" style="color:var(--db-accent,#6366f1);font-size:12px">Open trace →</a></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function selectCard(card) {
|
||||
selectedCard = card;
|
||||
if (card && (!activeTab)) activeTab = 'description';
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
let pendingHandled = !pendingCardId;
|
||||
function tryDeepLink(data) {
|
||||
for (const col of data.columns || []) {
|
||||
for (const card of col.cards || []) {
|
||||
if (card.id === pendingCardId) { pendingHandled = true; selectCard(card); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const board = createInteractiveBoard(p1Body, {
|
||||
apiBase: '/api/kanban',
|
||||
eventsUrl: '/api/kanban/events',
|
||||
pollMs: 8000,
|
||||
debounceMs: 500,
|
||||
interactive: true,
|
||||
laneByProfile: true,
|
||||
boardSwitcher: true,
|
||||
boardState,
|
||||
onOpen: (card) => {
|
||||
pendingHandled = true;
|
||||
selectCard(card);
|
||||
},
|
||||
fetch: async () => {
|
||||
const [boardRes, boardsRes] = await Promise.all([
|
||||
fetch(`/api/kanban/board?board=${encodeURIComponent(boardState.id)}`),
|
||||
fetch('/api/kanban/boards'),
|
||||
]);
|
||||
if (!boardRes.ok) throw new Error(await boardRes.text());
|
||||
const data = await boardRes.json();
|
||||
if (boardsRes.ok) {
|
||||
const b = await boardsRes.json();
|
||||
data.boards = b.boards || [{ id: 'default', title: 'Default' }];
|
||||
}
|
||||
if (!pendingHandled && pendingCardId) tryDeepLink(data);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
|
||||
return () => board.destroy();
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Unit tests for Skills Studio (STITCH-008, #218).
|
||||
|
||||
Covers:
|
||||
* skills.* i18n keys across locales.
|
||||
* Frontend view content assertions (tabs, diff overlay, deep-link, APIs).
|
||||
* XSS-safety of the proposed-content diff render.
|
||||
* approvals.js cross-link for skill-type rows.
|
||||
* Python-mirrored isSkillApproval (R1-R5) predicate and LCS line diff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiui.features.i18n import JSONLocaleManager
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
)
|
||||
SKILLS_JS = FRONTEND / "skills.js"
|
||||
APPROVALS_JS = FRONTEND / "approvals.js"
|
||||
HELPERS_JS = FRONTEND / "_helpers.js"
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
KEYS = [
|
||||
"skills.title",
|
||||
"skills.subtitle",
|
||||
"skills.tab.pending",
|
||||
"skills.tab.installed",
|
||||
"skills.tab.history",
|
||||
"skills.diff.current",
|
||||
"skills.diff.proposed",
|
||||
"skills.diff.no_existing",
|
||||
"skills.action.approve",
|
||||
"skills.action.reject",
|
||||
"skills.pending.empty",
|
||||
]
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
class TestHelpersJs:
|
||||
def test_helpers_export_functions(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "export function diffLines(" in src
|
||||
assert "export function renderMarkdownPreview(" in src
|
||||
assert "export function isSkillApproval(" in src
|
||||
|
||||
def test_diff_normalises_crlf(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "replace(/\\r\\n?/g, '\\n')" in src
|
||||
|
||||
def test_preview_escapes_first(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "esc(s)" in src
|
||||
|
||||
|
||||
class TestSkillsJs:
|
||||
def test_exports_render_and_cleanup(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "export async function render" in src
|
||||
assert "export function cleanup" in src
|
||||
|
||||
def test_tab_shell_present(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "class=\"ss-tab\"" in src
|
||||
assert "tabBtn('pending'" in src
|
||||
assert "tabBtn('installed'" in src
|
||||
assert "tabBtn('history'" in src
|
||||
|
||||
def test_url_state_and_deep_link(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "approval_id" in src
|
||||
assert "currentApprovalId(" in src
|
||||
assert "openDiff(" in src
|
||||
|
||||
def test_uses_existing_apis(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "/api/approvals/pending" in src
|
||||
assert "/api/approvals/history" in src
|
||||
assert "/api/skills" in src
|
||||
assert "/api/skills/${cb.dataset.id}/toggle" in src
|
||||
assert "/api/approvals/${id}/${verb}" in src
|
||||
|
||||
def test_imports_skill_helpers(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "isSkillApproval" in src
|
||||
assert "diffLines" in src
|
||||
assert "renderMarkdownPreview" in src
|
||||
|
||||
def test_no_browser_eval(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "eval(" not in src
|
||||
|
||||
def test_proposed_content_escaped_in_diff(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
# Diff rows must escape line text; no raw interpolation of r.text.
|
||||
assert "esc(r.text)" in src
|
||||
assert "${r.text}" not in src
|
||||
|
||||
def test_empty_state_and_no_existing_labels(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "No pending skill writes" in src
|
||||
assert "No existing skill" in src
|
||||
|
||||
|
||||
class TestApprovalsCrossLink:
|
||||
def test_skill_row_links_to_studio(self):
|
||||
src = APPROVALS_JS.read_text()
|
||||
assert "isSkillApproval" in src
|
||||
assert "/skills?tab=pending&approval_id=" in src
|
||||
|
||||
|
||||
# ── Python mirrors of the JS predicate + diff for logic verification ──
|
||||
|
||||
|
||||
def is_skill_approval(a: dict) -> bool:
|
||||
names = {"write_skill", "skill_write", "save_skill", "create_skill"}
|
||||
if str(a.get("tool_name", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("action", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("type", "")).lower() == "skill_write":
|
||||
return True
|
||||
meta = a.get("metadata") or {}
|
||||
if str(meta.get("kind", "")).lower() == "skill":
|
||||
return True
|
||||
path = str((a.get("arguments") or {}).get("path", ""))
|
||||
if path.endswith("SKILL.md") or ".praisonai/skills/" in path:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def diff_lines(before: str, after: str) -> list[tuple[str, str]]:
|
||||
a = before.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
b = after.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
n, m = len(a), len(b)
|
||||
lcs = [[0] * (m + 1) for _ in range(n + 1)]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(m - 1, -1, -1):
|
||||
lcs[i][j] = (
|
||||
lcs[i + 1][j + 1] + 1
|
||||
if a[i] == b[j]
|
||||
else max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
)
|
||||
out: list[tuple[str, str]] = []
|
||||
i = j = 0
|
||||
while i < n and j < m:
|
||||
if a[i] == b[j]:
|
||||
out.append(("ctx", a[i]))
|
||||
i += 1
|
||||
j += 1
|
||||
elif lcs[i + 1][j] >= lcs[i][j + 1]:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
else:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
while i < n:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
while j < m:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
return out
|
||||
|
||||
|
||||
class TestSkillPredicate:
|
||||
def test_r1_tool_name(self):
|
||||
assert is_skill_approval({"tool_name": "write_skill"})
|
||||
assert is_skill_approval({"tool_name": "Create_Skill"})
|
||||
|
||||
def test_r2_action(self):
|
||||
assert is_skill_approval({"action": "save_skill"})
|
||||
|
||||
def test_r3_type(self):
|
||||
assert is_skill_approval({"type": "skill_write"})
|
||||
|
||||
def test_r4_metadata_kind(self):
|
||||
assert is_skill_approval({"metadata": {"kind": "skill"}})
|
||||
|
||||
def test_r5_path(self):
|
||||
assert is_skill_approval({"arguments": {"path": "x/y/SKILL.md"}})
|
||||
assert is_skill_approval({"arguments": {"path": ".praisonai/skills/z/x"}})
|
||||
|
||||
def test_non_skill_rejected(self):
|
||||
assert not is_skill_approval({"tool_name": "run_shell"})
|
||||
assert not is_skill_approval({"arguments": {"path": "notes.txt"}})
|
||||
assert not is_skill_approval({})
|
||||
|
||||
|
||||
class TestDiffLines:
|
||||
def test_add_and_delete_counts(self):
|
||||
rows = diff_lines("a\nb\nc", "a\nx\nc")
|
||||
adds = [r for r in rows if r[0] == "add"]
|
||||
dels = [r for r in rows if r[0] == "del"]
|
||||
ctx = [r for r in rows if r[0] == "ctx"]
|
||||
assert len(adds) == 1 and adds[0][1] == "x"
|
||||
assert len(dels) == 1 and dels[0][1] == "b"
|
||||
assert len(ctx) == 2
|
||||
|
||||
def test_new_skill_additions(self):
|
||||
rows = diff_lines("", "line1\nline2")
|
||||
assert any(r[0] == "add" and r[1] == "line1" for r in rows)
|
||||
assert any(r[0] == "add" and r[1] == "line2" for r in rows)
|
||||
|
||||
def test_crlf_normalised(self):
|
||||
rows = diff_lines("a\r\nb", "a\nb")
|
||||
assert all(r[0] == "ctx" for r in rows)
|
||||
|
||||
def test_hundred_line_diff(self):
|
||||
before = "\n".join(f"l{i}" for i in range(100))
|
||||
after = "\n".join(("CHANGED" if i == 50 else f"l{i}") for i in range(100))
|
||||
rows = diff_lines(before, after)
|
||||
assert any(r[0] == "add" and r[1] == "CHANGED" for r in rows)
|
||||
assert any(r[0] == "del" and r[1] == "l50" for r in rows)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Work Hub (STITCH-003) registration and wiring tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
VIEWS = FRONTEND / "views"
|
||||
|
||||
|
||||
def test_work_hub_view_file_exists():
|
||||
assert (VIEWS / "work-hub.js").exists()
|
||||
|
||||
|
||||
def test_work_registered_in_builtin_views():
|
||||
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "work:" in source
|
||||
assert "/plugins/views/work-hub.js" in source
|
||||
|
||||
|
||||
def test_kanban_banner_links_to_work_hub():
|
||||
source = (VIEWS / "kanban.js").read_text(encoding="utf-8")
|
||||
assert 'href="/work"' in source
|
||||
assert "Work Hub" in source
|
||||
|
||||
|
||||
def test_board_supports_on_open_override():
|
||||
source = (FRONTEND / "board.js").read_text(encoding="utf-8")
|
||||
assert "typeof opts.onOpen === 'function'" in source
|
||||
|
||||
|
||||
def test_work_page_registered_in_server():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui as aiui
|
||||
import praisonaiui.server as server
|
||||
|
||||
server.reset_state()
|
||||
aiui.set_style("dashboard")
|
||||
app = server.create_app()
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/pages")
|
||||
assert resp.status_code == 200
|
||||
pages = resp.json().get("pages") or []
|
||||
work = next((p for p in pages if p.get("id") == "work"), None)
|
||||
assert work is not None
|
||||
assert work["group"] == "Work"
|
||||
Reference in new issue
Block a user