mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 13:56:36 -10:00
Compare commits
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.128"
|
||||
version = "0.3.133"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -199,11 +199,11 @@ text = text.replace(
|
||||
root.appendChild(sidebar);
|
||||
setupMobileNav(root, sidebar);
|
||||
}
|
||||
root.appendChild(main);
|
||||
const shellHeader = document.createElement('div');
|
||||
shellHeader.id = 'db-shell-header-slot';
|
||||
renderSlot('shell:header', shellHeader);
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
|
||||
root.appendChild(main);""",
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
|
||||
)
|
||||
|
||||
# 6) selectPage toolbar
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.128"
|
||||
__version__ = "0.3.133"
|
||||
@@ -1596,10 +1596,18 @@ def run(
|
||||
# Run gateway - check if user module defines its own main function
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
|
||||
if hasattr(user_module, 'main') and callable(getattr(user_module, 'main')):
|
||||
# Call user's main function directly (gateway pattern)
|
||||
main_func = getattr(user_module, 'main')
|
||||
# Propagate CLI --port/--host to gateway examples that read the
|
||||
# PORT/HOST environment variables. Explicit env set by the user
|
||||
# takes precedence over the CLI flag.
|
||||
if "PORT" not in os.environ:
|
||||
os.environ["PORT"] = str(actual_port)
|
||||
if "HOST" not in os.environ:
|
||||
os.environ["HOST"] = host
|
||||
if inspect.iscoroutinefunction(main_func):
|
||||
console.print("[yellow]ℹ️[/yellow] Delegating to user-defined async main()")
|
||||
asyncio.run(main_func())
|
||||
|
||||
@@ -76,6 +76,17 @@ SECRET_FIELD_PATTERNS = {
|
||||
"private_key", "access_token", "refresh_token", "webhook_secret"
|
||||
}
|
||||
|
||||
# Suffixes that identify secret-bearing config keys not covered by the
|
||||
# exact-match SECRET_FIELD_PATTERNS set (e.g. app_token, signing_secret).
|
||||
_SECRET_KEY_SUFFIXES = ("_token", "_key", "_secret", "_password")
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
"""True if a config key holds a secret and must be redacted on read."""
|
||||
lowered = key.lower()
|
||||
return lowered in SECRET_FIELD_PATTERNS or lowered.endswith(_SECRET_KEY_SUFFIXES)
|
||||
|
||||
|
||||
def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Validate channel config doesn't contain inline secrets.
|
||||
|
||||
@@ -84,7 +95,7 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS:
|
||||
if _is_secret_key(key):
|
||||
if isinstance(value, str) and value and not value.startswith("env:"):
|
||||
# Check for common secret patterns
|
||||
if (value.startswith(("sk-", "xoxb-", "xoxp-", "xapp-", "am_", "bot")) or
|
||||
@@ -93,15 +104,25 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
f"'{key}_ref': 'env:YOUR_ENV_VAR'")
|
||||
return None
|
||||
|
||||
|
||||
def _redact_config_secrets(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Redact secret values in config for API responses."""
|
||||
"""Redact secret values in config for API responses.
|
||||
|
||||
Any key matching a known secret name or a secret-bearing suffix is
|
||||
replaced with ``***REDACTED***`` and a ``<key>_set`` boolean is added so
|
||||
the UI can indicate whether a value is configured without exposing it.
|
||||
Values referencing env vars (``env:...``) are left as-is — they contain
|
||||
no literal secret.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
redacted = config.copy()
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS and isinstance(value, str) and value:
|
||||
if not value.startswith("env:"):
|
||||
redacted[key] = "***REDACTED***"
|
||||
if _is_secret_key(key) and isinstance(value, str) and value:
|
||||
if value.startswith("env:"):
|
||||
continue
|
||||
redacted[key] = "***REDACTED***"
|
||||
redacted[f"{key}_set"] = True
|
||||
return redacted
|
||||
|
||||
def _get_config_secret(config: Dict[str, Any], key: str, env_fallback: Optional[str] = None) -> str:
|
||||
@@ -1030,7 +1051,11 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
channel["start_error"] = error
|
||||
else:
|
||||
await self._stop_channel_bot(channel_id)
|
||||
return JSONResponse(channel)
|
||||
|
||||
# Redact secrets in response
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
return JSONResponse(safe_channel)
|
||||
|
||||
async def _status(self, request: Request) -> JSONResponse:
|
||||
channel_id = request.path_params["channel_id"]
|
||||
|
||||
@@ -671,6 +671,93 @@ async def _run_and_broadcast(
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_LIMIT = 128000
|
||||
_TOKENS_PER_CHAR = 0.25
|
||||
_SYSTEM_BASELINE_TOKENS = 800
|
||||
_TOOLS_BASELINE_TOKENS = 400
|
||||
|
||||
|
||||
def _estimate_context_stats(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
limit: int = DEFAULT_CONTEXT_LIMIT,
|
||||
model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compute a char-based context usage estimate for a session.
|
||||
|
||||
No tokenizer dependency: approximates tokens as ``chars * 0.25`` and
|
||||
adds fixed system/tools baselines. Returns a wire-safe dict with a
|
||||
deterministic, sorted breakdown. ``estimate`` is always ``True`` since
|
||||
this is a heuristic, not a provider-reported count.
|
||||
"""
|
||||
message_chars = 0
|
||||
tool_chars = 0
|
||||
memory_chars = 0
|
||||
for m in messages:
|
||||
content = m.get("content") or ""
|
||||
message_chars += len(str(content))
|
||||
tool_calls = m.get("toolCalls") or m.get("tool_calls") or []
|
||||
for tc in tool_calls:
|
||||
tool_chars += len(str(tc.get("args", "")))
|
||||
tool_chars += len(str(tc.get("result", "")))
|
||||
for el in m.get("elements") or []:
|
||||
memory_chars += len(str(el.get("url", "")))
|
||||
|
||||
system_tokens = _SYSTEM_BASELINE_TOKENS
|
||||
tools_tokens = _TOOLS_BASELINE_TOKENS + int(tool_chars * _TOKENS_PER_CHAR)
|
||||
messages_tokens = int(message_chars * _TOKENS_PER_CHAR)
|
||||
memory_tokens = int(memory_chars * _TOKENS_PER_CHAR)
|
||||
|
||||
breakdown = {
|
||||
"memory": memory_tokens,
|
||||
"messages": messages_tokens,
|
||||
"system": system_tokens,
|
||||
"tools": tools_tokens,
|
||||
}
|
||||
used = system_tokens + tools_tokens + messages_tokens + memory_tokens
|
||||
safe_limit = limit if limit and limit > 0 else DEFAULT_CONTEXT_LIMIT
|
||||
usage_pct = round((used / safe_limit) * 100, 1) if safe_limit else 0.0
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"breakdown": breakdown,
|
||||
"estimate": True,
|
||||
"limit": limit,
|
||||
"session_id": session_id,
|
||||
"usage_pct": usage_pct,
|
||||
"used": used,
|
||||
}
|
||||
if model:
|
||||
result["model"] = model
|
||||
return result
|
||||
|
||||
|
||||
async def _chat_context_stats(request: Request) -> JSONResponse:
|
||||
"""GET /api/chat/context-stats?session_id= — context budget estimate.
|
||||
|
||||
Config-driven fallback for the composer token ring. Computes a
|
||||
char-based estimate from persisted messages; unknown sessions return
|
||||
a zero-usage estimate rather than 404 so the UI can always render.
|
||||
"""
|
||||
session_id = request.query_params.get("session_id", "")
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(DEFAULT_CONTEXT_LIMIT)))
|
||||
except (TypeError, ValueError):
|
||||
limit = DEFAULT_CONTEXT_LIMIT
|
||||
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if session_id:
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
messages = await _datastore.get_messages(session_id) or []
|
||||
except Exception:
|
||||
messages = []
|
||||
|
||||
stats = _estimate_context_stats(messages, session_id=session_id, limit=limit)
|
||||
return JSONResponse(stats)
|
||||
|
||||
|
||||
async def _chat_history(request: Request) -> JSONResponse:
|
||||
"""GET /api/chat/history/{session_id} — get message history."""
|
||||
session_id = request.path_params["session_id"]
|
||||
@@ -854,6 +941,7 @@ class ChatFeature(BaseFeatureProtocol):
|
||||
def routes(self) -> List[Route]:
|
||||
return [
|
||||
Route("/api/chat/send", _chat_send, methods=["POST"]),
|
||||
Route("/api/chat/context-stats", _chat_context_stats, methods=["GET"]),
|
||||
Route("/api/chat/history/{session_id}", _chat_history, methods=["GET"]),
|
||||
Route("/api/chat/abort", _chat_abort, methods=["POST"]),
|
||||
WebSocketRoute("/api/chat/ws", _chat_ws),
|
||||
|
||||
@@ -73,6 +73,14 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Type your message...",
|
||||
"chat.send": "Send",
|
||||
"chat.thinking": "Thinking...",
|
||||
"chat.context.healthy": "Context healthy",
|
||||
"chat.context.warning": "Approaching context limit",
|
||||
"chat.context.critical": "Context nearly full",
|
||||
"chat.context.estimate": "Estimate",
|
||||
"chat.compaction.banner": "Context nearly full \u2014 older messages may be summarized on next send",
|
||||
"chat.compaction.compacted": "Context compacted",
|
||||
"chat.memory.chip": "{count} memories",
|
||||
"chat.memory.empty_turn": "No memories for this turn",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Settings",
|
||||
@@ -86,6 +94,14 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Escribe tu mensaje...",
|
||||
"chat.send": "Enviar",
|
||||
"chat.thinking": "Pensando...",
|
||||
"chat.context.healthy": "Contexto saludable",
|
||||
"chat.context.warning": "Acercándose al límite de contexto",
|
||||
"chat.context.critical": "Contexto casi lleno",
|
||||
"chat.context.estimate": "Estimación",
|
||||
"chat.compaction.banner": "Contexto casi lleno \u2014 los mensajes antiguos pueden resumirse al enviar",
|
||||
"chat.compaction.compacted": "Contexto compactado",
|
||||
"chat.memory.chip": "{count} recuerdos",
|
||||
"chat.memory.empty_turn": "Sin recuerdos para este turno",
|
||||
"nav.dashboard": "Panel",
|
||||
"nav.agents": "Agentes",
|
||||
"nav.settings": "Configuración",
|
||||
@@ -99,6 +115,14 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Tapez votre message...",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.thinking": "Réflexion...",
|
||||
"chat.context.healthy": "Contexte sain",
|
||||
"chat.context.warning": "Approche de la limite de contexte",
|
||||
"chat.context.critical": "Contexte presque plein",
|
||||
"chat.context.estimate": "Estimation",
|
||||
"chat.compaction.banner": "Contexte presque plein \u2014 les anciens messages peuvent être résumés au prochain envoi",
|
||||
"chat.compaction.compacted": "Contexte compacté",
|
||||
"chat.memory.chip": "{count} souvenirs",
|
||||
"chat.memory.empty_turn": "Aucun souvenir pour ce tour",
|
||||
"nav.dashboard": "Tableau de bord",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Paramètres",
|
||||
|
||||
@@ -116,7 +116,14 @@ class OpenAIRealtimeManager(RealtimeProtocol):
|
||||
logger.debug(f"Audio chunk for session {session_id}: {len(audio_data)} bytes")
|
||||
|
||||
async def receive_audio(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Receive events from OpenAI realtime session."""
|
||||
"""Receive events from OpenAI realtime session.
|
||||
|
||||
First-event contract:
|
||||
- Unknown session -> ``error`` (Session not found)
|
||||
- ``OPENAI_API_KEY`` set -> ``session.ready`` (WebRTC handshake)
|
||||
- Integrated mode, no key -> ``error`` (status=degraded)
|
||||
- Standalone, no key -> ``conversation.item.created`` (mock)
|
||||
"""
|
||||
# In WebRTC mode, events come via WebRTC connection
|
||||
# This method would handle transcript/tool call events
|
||||
session = self._sessions.get(session_id)
|
||||
|
||||
@@ -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",
|
||||
@@ -3372,6 +3380,14 @@ def create_app(
|
||||
"description": "Agent skills & plugins",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"id": "marketplace",
|
||||
"title": "Marketplace",
|
||||
"icon": "🧩",
|
||||
"group": "Agent",
|
||||
"description": "Browse, install & manage plugins",
|
||||
"order": 22,
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"title": "Memory",
|
||||
@@ -3452,6 +3468,14 @@ def create_app(
|
||||
"description": "Agent evaluation & accuracy",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "code-studio",
|
||||
"title": "Code Studio",
|
||||
"icon": "💻",
|
||||
"group": "Control",
|
||||
"description": "Write, run, and share code in a sandbox",
|
||||
"order": 45,
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"title": "Telemetry",
|
||||
@@ -3499,6 +3523,8 @@ def create_app(
|
||||
"cron": "/api/schedules",
|
||||
"channels": "/api/channels",
|
||||
"mcp": "/api/mcp/servers",
|
||||
"marketplace": "/api/marketplace/plugins",
|
||||
"code-studio": "/api/code/languages",
|
||||
}
|
||||
|
||||
# Read page whitelist from config.yaml if set_pages() was not called
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -386,16 +386,17 @@ function injectStyles() {
|
||||
}
|
||||
|
||||
function checkForChannelsPage(root) {
|
||||
// Look for a channels page container or dashboard section
|
||||
const channelsSection = root.querySelector('[data-page="channels"]') ||
|
||||
root.querySelector('.channels-page') ||
|
||||
// The dashboard channels view (plugins/views/channels.js) now owns the full
|
||||
// CRUD UI and mounts into the [data-page="channels"] container. Only take over
|
||||
// legacy standalone containers that the dashboard view does not manage, so we
|
||||
// never clobber the Add Channel modal and card actions.
|
||||
const channelsSection = root.querySelector('.channels-page') ||
|
||||
root.querySelector('#channels');
|
||||
|
||||
|
||||
if (channelsSection && !channelsSection.hasAttribute('data-aiui-channels')) {
|
||||
channelsSection.setAttribute('data-aiui-channels', 'true');
|
||||
fetchChannels().then(renderChannelsUI);
|
||||
|
||||
// Set up auto-refresh every 30 seconds
|
||||
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
refreshInterval = setInterval(async () => {
|
||||
await fetchChannels();
|
||||
|
||||
@@ -171,10 +171,13 @@ 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',
|
||||
mcp: '/plugins/views/mcp.js',
|
||||
marketplace: '/plugins/views/marketplace.js',
|
||||
'code-studio': '/plugins/views/code-studio.js',
|
||||
};
|
||||
|
||||
const SLOT_REGISTRY = {};
|
||||
@@ -316,8 +319,12 @@ const DASHBOARD_STYLE = `
|
||||
padding: 20px; font-size: 15px; font-weight: 600;
|
||||
letter-spacing: -0.01em; border-bottom: 1px solid var(--db-border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
min-width: 0; padding-right: 28px;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; }
|
||||
.db-sidebar-header > span:not(.logo) {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; flex-shrink: 0; }
|
||||
.db-sidebar-toggle {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
@@ -480,6 +487,9 @@ const DASHBOARD_STYLE = `
|
||||
@keyframes db-spin { to { transform: rotate(360deg); } }
|
||||
.db-spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--db-border); border-top-color: var(--db-accent); border-radius: 50%; animation: db-spin 0.8s linear infinite; }
|
||||
|
||||
/* Init error banner */
|
||||
.db-error { margin: 24px; padding: 16px 20px; border: 1px solid #b91c1c; border-radius: 8px; background: rgba(185, 28, 28, 0.12); color: #fca5a5; font-size: 14px; }
|
||||
|
||||
/* Hide React docs layout when dashboard is active */
|
||||
.db-active .sidebar:not(.db-sidebar), .db-active .topnav, .db-active .toc-sidebar,
|
||||
.db-active > .main-content, .db-active > nav:not(.db-sidebar) { display: none !important; }
|
||||
@@ -583,6 +593,22 @@ const DASHBOARD_STYLE = `
|
||||
appearance: none; cursor: pointer;
|
||||
}
|
||||
.db-form-select:focus { border-color: var(--db-accent); }
|
||||
.db-form-select option, select.db-form-select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-form-select option:checked, select.db-form-select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-active select option, #root select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-active select option:checked, #root select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-form-textarea {
|
||||
width: 100%; padding: 8px 12px; font-size: 13px; color: var(--db-text);
|
||||
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: 6px;
|
||||
@@ -1064,7 +1090,11 @@ function closeCommandPalette() {
|
||||
_paletteResults = [];
|
||||
}
|
||||
|
||||
let _paletteInitialized = false;
|
||||
|
||||
function initCommandPalette() {
|
||||
if (_paletteInitialized) return;
|
||||
_paletteInitialized = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
@@ -1073,6 +1103,11 @@ function initCommandPalette() {
|
||||
});
|
||||
}
|
||||
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openCommandPalette = openCommandPalette;
|
||||
window.aiui.closeCommandPalette = closeCommandPalette;
|
||||
window.openCommandPalette = openCommandPalette;
|
||||
|
||||
// ── Session Search Palette ──────────────────────────────────────────
|
||||
// Vanilla-JS port of the React SessionSearch / useSessionSearch behavior so
|
||||
// that the dashboard style reaches C-01 parity. The palette lists sessions
|
||||
@@ -1246,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;
|
||||
@@ -1261,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
|
||||
@@ -1495,6 +1846,20 @@ function buildSidebar(pages) {
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
function showDashboardInitError(message) {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return;
|
||||
let banner = document.getElementById('db-init-error');
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'db-init-error';
|
||||
banner.className = 'db-error';
|
||||
banner.setAttribute('role', 'alert');
|
||||
root.appendChild(banner);
|
||||
}
|
||||
banner.textContent = message;
|
||||
}
|
||||
|
||||
async function selectPage(pageId) {
|
||||
// Cleanup previous view if it has a cleanup function
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
|
||||
@@ -1513,7 +1878,15 @@ async function selectPage(pageId) {
|
||||
|
||||
const page = pagesData.find(p => p.id === pageId);
|
||||
const main = document.getElementById('db-main-content');
|
||||
if (!main || !page) return;
|
||||
if (!main) {
|
||||
console.error('[AIUI] selectPage aborted: #db-main-content not in DOM — dashboard shell incomplete');
|
||||
showDashboardInitError('Dashboard shell layout incomplete. Reload the page or check the dashboard plugin.');
|
||||
return;
|
||||
}
|
||||
if (!page) {
|
||||
console.error(`[AIUI] selectPage aborted: page '${pageId}' not found in pagesData`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set page header (controlled by dashboard config protocol)
|
||||
const showPageHeader = main.dataset.showPageHeader !== 'false';
|
||||
|
||||
@@ -57,6 +57,101 @@ 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">
|
||||
|
||||
@@ -1,8 +1,266 @@
|
||||
/** Channels view — delegates to channels dashboard module. */
|
||||
/**
|
||||
* Channels View — Multi-platform messaging channel management with CRUD.
|
||||
*
|
||||
* Restores the Add Channel modal, per-platform credential fields, setup hints,
|
||||
* and card actions (Enable/Disable, Restart, Test, Delete) removed in the
|
||||
* 3961508 refactor. Aligned with #176 secret policy: credential inputs default
|
||||
* to env references (env:VAR_NAME), stored secrets are never re-displayed
|
||||
* (uses *_set flags from the redacted GET), and API 400 validation errors are
|
||||
* surfaced inline in the modal.
|
||||
*
|
||||
* API: /api/channels, /api/channels/platforms, /api/channels/{id}/{toggle,restart,test}
|
||||
*/
|
||||
import { showToast, showConfirm } from '../toast.js';
|
||||
|
||||
const PLATFORM_META = {
|
||||
discord: { icon: '🎮', color: '#5865F2', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
|
||||
slack: { icon: '💬', color: '#4A154B', fields: [
|
||||
{ key: 'bot_token', label: 'Bot Token', secret: true },
|
||||
{ key: 'app_token', label: 'App Token', secret: true },
|
||||
{ key: 'signing_secret', label: 'Signing Secret', secret: true, optional: true },
|
||||
] },
|
||||
telegram: { icon: '✈️', color: '#229ED9', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
|
||||
whatsapp: { icon: '📱', color: '#25D366', fields: [
|
||||
{ key: 'access_token', label: 'Access Token', secret: true },
|
||||
{ key: 'phone_number_id', label: 'Phone Number ID' },
|
||||
{ key: 'verify_token', label: 'Verify Token', secret: true, optional: true },
|
||||
] },
|
||||
imessage: { icon: '🍎', color: '#007AFF', fields: [
|
||||
{ key: 'apple_id', label: 'Apple ID' },
|
||||
{ key: 'handler_path', label: 'Handler Path' },
|
||||
] },
|
||||
signal: { icon: '🔒', color: '#3A76F0', fields: [
|
||||
{ key: 'phone_number', label: 'Phone Number' },
|
||||
{ key: 'api_url', label: 'API URL' },
|
||||
] },
|
||||
googlechat: { icon: '💼', color: '#00AC47', fields: [
|
||||
{ key: 'service_account', label: 'Service Account', secret: true },
|
||||
{ key: 'space_name', label: 'Space Name' },
|
||||
] },
|
||||
nostr: { icon: '🟣', color: '#9B59B6', fields: [
|
||||
{ key: 'private_key', label: 'Private Key (nsec)', secret: true },
|
||||
{ key: 'relay_url', label: 'Relay URL' },
|
||||
] },
|
||||
};
|
||||
|
||||
const SETUP_HINTS = {
|
||||
discord: '1. Create a Bot at discord.com/developers\n2. Enable Message Content Intent\n3. Copy the Bot Token\n4. Invite bot to your server',
|
||||
slack: '1. Create a Slack App at api.slack.com\n2. Add Bot Token Scopes (chat:write, app_mentions:read)\n3. Enable Socket Mode → copy App Token (xapp-...)\n4. Install to workspace → copy Bot Token (xoxb-...)',
|
||||
telegram: '1. Message @BotFather on Telegram\n2. Create a new bot with /newbot\n3. Copy the Bot Token',
|
||||
whatsapp: '1. Create a Meta Business App\n2. Set up WhatsApp Business API\n3. Copy Phone Number ID and Access Token',
|
||||
};
|
||||
|
||||
function metaFor(platform) {
|
||||
return PLATFORM_META[platform] || { icon: '📡', color: 'var(--db-accent)', fields: [] };
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'channels');
|
||||
const mod = await import('../channels.js');
|
||||
if (mod.default?.init) await mod.default.init();
|
||||
const root = container.closest('#root') || document.getElementById('root');
|
||||
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let channels = [];
|
||||
let platforms = [];
|
||||
try {
|
||||
const r = await fetch('/api/channels');
|
||||
const d = await r.json();
|
||||
channels = d.channels || d || [];
|
||||
if (!Array.isArray(channels)) {
|
||||
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
const r = await fetch('/api/channels/platforms');
|
||||
const d = await r.json();
|
||||
platforms = d.platforms || d || [];
|
||||
} catch (e) {}
|
||||
if (!Array.isArray(platforms) || platforms.length === 0) platforms = Object.keys(PLATFORM_META);
|
||||
|
||||
const online = channels.filter(c => c.running === true).length;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:24px;max-width:1200px;margin:0 auto">
|
||||
<div class="aiui-channels-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:1.5rem">Messaging Channels</h2>
|
||||
<div style="margin-top:4px;font-size:13px;color:var(--db-text-dim)">${online} Online · ${channels.length} Total</div>
|
||||
</div>
|
||||
<button id="ch-add" style="padding:8px 18px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:.85rem">+ Add Channel</button>
|
||||
</div>
|
||||
<div id="ch-grid" class="db-columns" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"></div>
|
||||
<div id="ch-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
|
||||
</div>`;
|
||||
|
||||
const grid = container.querySelector('#ch-grid');
|
||||
|
||||
if (channels.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="aiui-channels-empty" style="grid-column:1/-1;text-align:center;padding:3rem 2rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:3rem;opacity:.5;margin-bottom:1rem">📡</div>
|
||||
<h3 style="color:var(--db-text);margin:0 0 .5rem">No Channels Configured</h3>
|
||||
<p style="margin:.5rem 0">Click <strong>+ Add Channel</strong> to connect Discord, Slack, Telegram, or WhatsApp.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
channels.forEach(ch => {
|
||||
const platform = (ch.platform || ch.type || ch.id || '').toLowerCase();
|
||||
const meta = metaFor(platform);
|
||||
const enabled = ch.enabled !== false;
|
||||
const isRunning = ch.running === true;
|
||||
const hasError = !!(ch.start_error && ch.start_error !== '');
|
||||
const statusLabel = !enabled ? 'Disabled' : isRunning ? 'Connected' : hasError ? 'Error' : 'Stopped';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'padding:16px;border-radius:12px';
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
<div style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;background:${meta.color}22">${meta.icon}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:15px">${esc(ch.name || ch.id || platform)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:1px;text-transform:capitalize">${esc(platform)}</div>
|
||||
</div>
|
||||
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${isRunning ? 'background:rgba(34,197,94,.15);color:#22c55e' : hasError ? 'background:rgba(234,179,8,.15);color:#eab308' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${isRunning ? '●' : hasError ? '⚠' : '○'} ${statusLabel}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
|
||||
<button class="ch-toggle" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="ch-restart" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">⟳ Restart</button>
|
||||
<button class="ch-test" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">🔍 Test</button>
|
||||
<button class="ch-del" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.ch-toggle').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/toggle`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
render(container);
|
||||
} catch (e) { showToast('Toggle failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-restart').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/restart`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
showToast('Channel restart initiated', 'success');
|
||||
} catch (e) { showToast('Restart failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-test').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/test`, { method: 'POST' });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
const ok = r.ok && d.success !== false;
|
||||
showToast(ok ? 'Connection test passed!' : 'Test failed: ' + (d.error || `HTTP ${r.status}`), ok ? 'success' : 'error');
|
||||
if (ok) render(container);
|
||||
} catch (e) { showToast('Test failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-del').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!await showConfirm('Delete channel?', 'This action cannot be undone.')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
render(container);
|
||||
} catch (e) { showToast('Delete failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelector('#ch-add')?.addEventListener('click', () => openAddModal(container, platforms));
|
||||
}
|
||||
|
||||
function openAddModal(container, platforms) {
|
||||
const m = container.querySelector('#ch-modal');
|
||||
m.style.display = 'flex';
|
||||
m.innerHTML = `
|
||||
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:480px;max-width:92vw;max-height:80vh;overflow-y:auto">
|
||||
<h3 style="margin:0 0 20px;font-size:18px">Add Channel</h3>
|
||||
<label style="display:block;margin-bottom:14px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Platform</span>
|
||||
<select id="chf-platform" class="db-form-select">
|
||||
${platforms.map(p => `<option value="${esc(p)}">${metaFor(p).icon} ${esc(p.charAt(0).toUpperCase() + p.slice(1))}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel Name <span style="opacity:.5">(Optional)</span></span>
|
||||
<input id="chf-name" class="db-form-input" placeholder="my-bot">
|
||||
</label>
|
||||
<div id="chf-fields" style="margin-bottom:14px"></div>
|
||||
<div id="chf-error" style="display:none;margin-bottom:14px;padding:10px 14px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;font-size:12px;color:#ef4444"></div>
|
||||
<div id="chf-setup-hint" style="margin-bottom:20px;padding:10px 14px;background:rgba(99,102,241,.08);border-radius:8px;font-size:12px;color:var(--db-text-dim)"></div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||||
<button id="chf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
|
||||
<button id="chf-save" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Add Channel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const selectEl = m.querySelector('#chf-platform');
|
||||
const fieldsDiv = m.querySelector('#chf-fields');
|
||||
const hintDiv = m.querySelector('#chf-setup-hint');
|
||||
const errDiv = m.querySelector('#chf-error');
|
||||
|
||||
function updateFields() {
|
||||
const platform = selectEl.value;
|
||||
const meta = metaFor(platform);
|
||||
fieldsDiv.innerHTML = meta.fields.map(f => {
|
||||
const optTag = f.optional ? ' <span style="opacity:.5">(Optional)</span>' : '';
|
||||
const placeholder = f.secret ? `env:${platform.toUpperCase()}_${f.key.toUpperCase()}` : f.label;
|
||||
const helper = f.secret
|
||||
? '<span style="font-size:11px;color:var(--db-text-dim);display:block;margin-top:3px">Use an env reference — inline secrets are rejected.</span>'
|
||||
: '';
|
||||
return `<label style="display:block;margin-bottom:10px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">${esc(f.label)}${optTag}</span>
|
||||
<input class="chf-field db-form-input" data-field="${esc(f.key)}" data-optional="${f.optional ? '1' : '0'}" type="text" placeholder="${esc(placeholder)}">
|
||||
${helper}
|
||||
</label>`;
|
||||
}).join('');
|
||||
hintDiv.innerHTML = SETUP_HINTS[platform]
|
||||
? '<strong>Setup Steps:</strong><pre style="margin:6px 0 0;white-space:pre-wrap;font-family:inherit">' + esc(SETUP_HINTS[platform]) + '</pre>'
|
||||
: `Configure your ${esc(platform)} integration above.`;
|
||||
errDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
updateFields();
|
||||
selectEl.addEventListener('change', updateFields);
|
||||
m.querySelector('#chf-cancel').addEventListener('click', () => { m.style.display = 'none'; });
|
||||
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
|
||||
|
||||
m.querySelector('#chf-save').addEventListener('click', async () => {
|
||||
const platform = selectEl.value;
|
||||
const config = {};
|
||||
m.querySelectorAll('.chf-field').forEach(f => {
|
||||
const v = f.value.trim();
|
||||
if (v) config[f.dataset.field] = v;
|
||||
});
|
||||
const body = {
|
||||
platform,
|
||||
name: m.querySelector('#chf-name').value.trim() || platform + '-bot',
|
||||
enabled: true,
|
||||
config,
|
||||
};
|
||||
errDiv.style.display = 'none';
|
||||
try {
|
||||
const r = await fetch('/api/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errDiv.textContent = d.error || d.detail || `Request failed (HTTP ${r.status})`;
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
m.style.display = 'none';
|
||||
showToast('Channel added', 'success');
|
||||
render(container);
|
||||
} catch (e) {
|
||||
errDiv.textContent = e.message;
|
||||
errDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* Config-driven: auto-discovers agents and sessions from existing APIs.
|
||||
*/
|
||||
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let ws = null;
|
||||
let currentSessionId = null;
|
||||
@@ -27,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;
|
||||
@@ -275,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; }
|
||||
@@ -683,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>
|
||||
@@ -707,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>
|
||||
@@ -736,6 +818,17 @@ export async function render(container) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
|
||||
window.addEventListener('aiui:prefill-composer', (e) => {
|
||||
const text = e.detail && e.detail.text;
|
||||
if (!text) return;
|
||||
const el = document.getElementById('chat-input');
|
||||
if (!el) return;
|
||||
el.value = text;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
el.focus();
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
@@ -764,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)
|
||||
@@ -796,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;
|
||||
@@ -900,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';
|
||||
@@ -1063,6 +1170,7 @@ function connectWebSocket() {
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[Chat] WebSocket error:', e);
|
||||
setStatus('error');
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Chat] WebSocket connection failed:', e);
|
||||
@@ -1084,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':
|
||||
@@ -1168,6 +1294,8 @@ function handleWsMessage(data) {
|
||||
setStatus('connected');
|
||||
setStreaming(false);
|
||||
drainQueue();
|
||||
scheduleContextRefresh();
|
||||
refreshMemoryChip();
|
||||
break;
|
||||
|
||||
case 'team_run_error':
|
||||
@@ -1359,6 +1487,9 @@ function appendMessage(role, content, agentName, elements) {
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'chat-msg chat-msg-' + role;
|
||||
if (role === 'system' && typeof content === 'string' && content.startsWith('❌')) {
|
||||
msgEl.setAttribute('role', 'alert');
|
||||
}
|
||||
|
||||
const avatarEl = document.createElement('div');
|
||||
avatarEl.className = 'chat-msg-avatar';
|
||||
@@ -1706,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) {
|
||||
@@ -1728,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();
|
||||
}
|
||||
|
||||
@@ -1757,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>`;
|
||||
}
|
||||
@@ -1907,12 +2240,20 @@ function sendMessage() {
|
||||
if (pendingAttachments.length > 0) {
|
||||
payload.attachment_ids = pendingAttachments.map(a => a.id);
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
} catch (e) {
|
||||
const msg = 'Connection lost. Message not sent.';
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
// Fallback to HTTP
|
||||
setStatus('sending…');
|
||||
const offlineHint = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'You are offline. ' : '';
|
||||
fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1925,13 +2266,19 @@ function sendMessage() {
|
||||
}).then((r) => r.json()).then((data) => {
|
||||
if (data.error) {
|
||||
appendMessage('system', '❌ ' + data.error);
|
||||
setStatus('error');
|
||||
try { showToast(data.error, 'error'); } catch (_) {}
|
||||
} else if (data.content || data.response) {
|
||||
appendMessage('assistant', data.content || data.response, data.agent_name);
|
||||
setStatus('connected');
|
||||
} else {
|
||||
setStatus('connected');
|
||||
}
|
||||
setStatus('connected');
|
||||
}).catch((e) => {
|
||||
appendMessage('system', '❌ ' + e.message);
|
||||
const msg = offlineHint + (e.message || 'Network error');
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1996,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
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Code Studio View — VS Code-lite in-dashboard IDE.
|
||||
* Write code, run in sandbox, view output, and send snippets to chat.
|
||||
* API: GET /api/code/languages, POST /api/code/execute
|
||||
*/
|
||||
import { helpBanner } from '/plugins/views/_helpers.js';
|
||||
|
||||
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/[email protected]/min/vs';
|
||||
const HISTORY_MAX = 10;
|
||||
const SNIPPET_KEY = 'aiui_snippets';
|
||||
const OUTPUT_MAX = 200000;
|
||||
|
||||
const DEFAULT_CODE = {
|
||||
python: "print('hello from Code Studio')\n",
|
||||
javascript: "console.log('hello from Code Studio');\n",
|
||||
bash: "echo 'hello from Code Studio'\n",
|
||||
};
|
||||
|
||||
const MONACO_LANG = { python: 'python', javascript: 'javascript', bash: 'shell' };
|
||||
const FILE_EXT = { python: 'py', javascript: 'js', bash: 'sh' };
|
||||
|
||||
let editor = null;
|
||||
let monacoNs = null;
|
||||
let running = false;
|
||||
let timerId = null;
|
||||
const history = [];
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function currentCode() {
|
||||
if (editor && typeof editor.getValue === 'function') return editor.getValue();
|
||||
const ta = document.getElementById('cs-fallback');
|
||||
return ta ? ta.value : '';
|
||||
}
|
||||
|
||||
function setCode(code) {
|
||||
if (editor && typeof editor.setValue === 'function') editor.setValue(code);
|
||||
else { const ta = document.getElementById('cs-fallback'); if (ta) ta.value = code; }
|
||||
}
|
||||
|
||||
function setEditorLanguage(lang) {
|
||||
if (monacoNs && editor) {
|
||||
const model = editor.getModel();
|
||||
if (model) monacoNs.editor.setModelLanguage(model, MONACO_LANG[lang] || 'plaintext');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonaco(host, lang) {
|
||||
if (window.monaco) monacoNs = window.monaco;
|
||||
if (!monacoNs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = MONACO_CDN + '/loader.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
if (!window.require) throw new Error('Monaco loader unavailable');
|
||||
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||
monacoNs = await new Promise((resolve, reject) => {
|
||||
try { window.require(['vs/editor/editor.main'], () => resolve(window.monaco)); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
editor = monacoNs.editor.create(host, {
|
||||
value: DEFAULT_CODE[lang] || '',
|
||||
language: MONACO_LANG[lang] || 'plaintext',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono','SF Mono',Monaco,Consolas,monospace",
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
function mountFallback(host, lang) {
|
||||
host.innerHTML = `<textarea id="cs-fallback" spellcheck="false"
|
||||
style="width:100%;height:100%;box-sizing:border-box;resize:none;border:0;outline:none;padding:12px;background:#1e1e1e;color:#d4d4d4;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:13px;line-height:1.5">${escapeHtml(DEFAULT_CODE[lang] || '')}</textarea>`;
|
||||
}
|
||||
|
||||
function pickTab(container, tab) {
|
||||
container.querySelectorAll('.cs-tab').forEach((b) => {
|
||||
const on = b.dataset.tab === tab;
|
||||
b.style.color = on ? 'var(--db-text,#e4e4e7)' : 'var(--db-text-dim,#a1a1aa)';
|
||||
b.style.borderBottom = on ? '2px solid var(--db-accent,#6366f1)' : '2px solid transparent';
|
||||
});
|
||||
container.querySelectorAll('.cs-tab-panel').forEach((p) => {
|
||||
p.style.display = p.dataset.panel === tab ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(text) {
|
||||
const s = String(text == null ? '' : text);
|
||||
if (s.length <= OUTPUT_MAX) return { text: s, truncated: false };
|
||||
return { text: s.slice(0, OUTPUT_MAX), truncated: true };
|
||||
}
|
||||
|
||||
function renderOutput(container, result) {
|
||||
const out = result.output != null ? result.output : (result.stdout || '');
|
||||
const err = result.error != null ? result.error : (result.stderr || '');
|
||||
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
|
||||
const failed = ['error', 'failed', 'degraded'].includes(result.status);
|
||||
|
||||
const banner = container.querySelector('#cs-banner');
|
||||
if (failed) {
|
||||
banner.style.display = 'flex';
|
||||
const code = result.exit_code != null ? ` (exit ${result.exit_code})` : '';
|
||||
banner.querySelector('#cs-banner-text').textContent = `Execution ${result.status}${code}`;
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
|
||||
const o = truncate(out);
|
||||
const e = truncate(err);
|
||||
container.querySelector('#cs-out-body').innerHTML =
|
||||
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#22c55e">${escapeHtml(o.text) || '<span style="color:#71717a">(no output)</span>'}</pre>` +
|
||||
(o.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
|
||||
container.querySelector('#cs-err-body').innerHTML =
|
||||
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#ef4444">${escapeHtml(e.text) || '<span style="color:#71717a">(no errors)</span>'}</pre>` +
|
||||
(e.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
|
||||
container.querySelector('#cs-art-body').innerHTML = artifacts.length
|
||||
? `<pre style="margin:0;white-space:pre-wrap">${escapeHtml(JSON.stringify(artifacts, null, 2))}</pre>`
|
||||
: '<span style="color:#71717a">(no artifacts)</span>';
|
||||
|
||||
pickTab(container, failed && err ? 'errors' : 'output');
|
||||
}
|
||||
|
||||
function renderHistory(container) {
|
||||
const list = container.querySelector('#cs-history');
|
||||
if (!history.length) {
|
||||
list.innerHTML = '<div style="color:#71717a;font-size:12px;padding:8px">No runs yet</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = history.map((h, i) => {
|
||||
const ok = !['error', 'failed', 'degraded'].includes(h.result.status);
|
||||
const icon = ok ? '<span style="color:#22c55e">●</span>' : '<span style="color:#ef4444">●</span>';
|
||||
const dur = h.duration != null ? `${(h.duration / 1000).toFixed(1)}s` : '';
|
||||
return `<div class="cs-history-row" data-idx="${i}" style="display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;font-size:12px" onmouseover="this.style.background='rgba(255,255,255,.05)'" onmouseout="this.style.background='transparent'">
|
||||
${icon}<span style="flex:1">${escapeHtml(h.language)}</span><span style="color:#71717a">${dur}</span></div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.cs-history-row').forEach((row) => {
|
||||
row.addEventListener('click', () => {
|
||||
const h = history[Number(row.dataset.idx)];
|
||||
if (!h) return;
|
||||
const sel = container.querySelector('#cs-language');
|
||||
if (sel) { sel.value = h.language; setEditorLanguage(h.language); }
|
||||
setCode(h.code);
|
||||
renderOutput(container, h.result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pushHistory(container, entry) {
|
||||
history.unshift(entry);
|
||||
if (history.length > HISTORY_MAX) history.length = HISTORY_MAX;
|
||||
renderHistory(container);
|
||||
}
|
||||
|
||||
async function runCode(container) {
|
||||
if (running) return;
|
||||
const code = currentCode();
|
||||
if (!code.trim()) {
|
||||
alert('Enter code to run');
|
||||
return;
|
||||
}
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
const runBtn = container.querySelector('#cs-run');
|
||||
running = true;
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = 'Running…';
|
||||
const start = Date.now();
|
||||
timerId = setInterval(() => {
|
||||
runBtn.textContent = `Executing… ${((Date.now() - start) / 1000).toFixed(1)}s`;
|
||||
}, 100);
|
||||
|
||||
let result;
|
||||
try {
|
||||
const res = await fetch('/api/code/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang, code, timeout: 30 }),
|
||||
});
|
||||
result = await res.json();
|
||||
} catch (e) {
|
||||
result = { status: 'failed', error: String(e), language: lang };
|
||||
} finally {
|
||||
running = false;
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
runBtn.disabled = false;
|
||||
runBtn.textContent = '▶ Run';
|
||||
}
|
||||
|
||||
const duration = result.duration_ms != null ? result.duration_ms : (Date.now() - start);
|
||||
renderOutput(container, result);
|
||||
pushHistory(container, { language: lang, code, result, duration });
|
||||
}
|
||||
|
||||
function saveSnippet(container) {
|
||||
const code = currentCode();
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
try {
|
||||
const raw = localStorage.getItem(SNIPPET_KEY);
|
||||
const snippets = raw ? JSON.parse(raw) : [];
|
||||
snippets.push({ language: lang, code, ts: Date.now() });
|
||||
localStorage.setItem(SNIPPET_KEY, JSON.stringify(snippets));
|
||||
const btn = container.querySelector('#cs-save');
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 1500);
|
||||
} catch (e) {
|
||||
alert('Could not save snippet (storage full)');
|
||||
}
|
||||
}
|
||||
|
||||
function sendToAgent(container) {
|
||||
const code = currentCode();
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
const text = '```' + lang + '\n' + code + '\n```\nPlease review this code.';
|
||||
if (window.aiui && typeof window.aiui.selectPage === 'function') {
|
||||
Promise.resolve(window.aiui.selectPage('chat')).then(() => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
|
||||
});
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'code-studio');
|
||||
|
||||
let languages = [];
|
||||
try {
|
||||
const r = await fetch('/api/code/languages');
|
||||
const d = await r.json();
|
||||
languages = d.languages || [];
|
||||
} catch (e) {}
|
||||
if (!languages.length) {
|
||||
languages = [{ id: 'python', name: 'Python' }, { id: 'javascript', name: 'JavaScript' }, { id: 'bash', name: 'Bash' }];
|
||||
}
|
||||
const firstLang = languages[0].id;
|
||||
|
||||
const langOptions = languages
|
||||
.map((l) => `<option value="${escapeHtml(l.id)}">${escapeHtml(l.name || l.id)}</option>`)
|
||||
.join('');
|
||||
|
||||
container.innerHTML = helpBanner({
|
||||
title: 'Code Studio',
|
||||
what: 'Write code, run it in a sandbox, inspect output, and hand snippets to your agent.',
|
||||
howToUse: 'Pick a language, edit the code, then click Run. Use "Send to Agent" to continue in chat.',
|
||||
tip: 'Code runs server-side in a sandbox — never in your browser.',
|
||||
collapsed: true,
|
||||
}) + `
|
||||
<div class="cs-root" style="display:flex;gap:12px;height:calc(100vh - 220px);min-height:480px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;border:1px solid var(--db-border);border-radius:10px;overflow:hidden;background:#1e1e1e">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px;background:#18181b;border-bottom:1px solid var(--db-border);flex-wrap:wrap">
|
||||
<select id="cs-language" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px">${langOptions}</select>
|
||||
<input id="cs-filename" type="text" value="script.${FILE_EXT[firstLang] || 'txt'}" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px;width:140px" />
|
||||
<button id="cs-run" style="padding:6px 16px;background:#22c55e;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">▶ Run</button>
|
||||
<button id="cs-save" style="padding:6px 14px;background:transparent;color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;cursor:pointer;font-size:13px">Save</button>
|
||||
</div>
|
||||
<div id="cs-editor" style="flex:3;min-height:180px"></div>
|
||||
<div style="flex:2;display:flex;flex-direction:column;border-top:1px solid var(--db-border);background:#0d1117;min-height:120px">
|
||||
<div id="cs-banner" style="display:none;align-items:center;gap:10px;padding:8px 12px;background:rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.3);font-size:12px">
|
||||
<span id="cs-banner-text" style="color:#ef4444;flex:1"></span>
|
||||
<button id="cs-fix" style="padding:4px 12px;background:transparent;color:#ef4444;border:1px solid rgba(239,68,68,.4);border-radius:6px;cursor:pointer;font-size:12px">Ask agent to fix</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 8px;border-bottom:1px solid var(--db-border)">
|
||||
<button class="cs-tab" data-tab="output" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Output</button>
|
||||
<button class="cs-tab" data-tab="errors" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Errors</button>
|
||||
<button class="cs-tab" data-tab="artifacts" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Artifacts</button>
|
||||
</div>
|
||||
<div style="flex:1;overflow:auto;padding:12px;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:12px">
|
||||
<div class="cs-tab-panel" data-panel="output" id="cs-out-body"><span style="color:#71717a">Run code to see output</span></div>
|
||||
<div class="cs-tab-panel" data-panel="errors" id="cs-err-body" style="display:none"></div>
|
||||
<div class="cs-tab-panel" data-panel="artifacts" id="cs-art-body" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:240px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg)">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">History</div>
|
||||
<div id="cs-history"></div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg);font-size:12px;color:var(--db-text-dim)">
|
||||
<div style="font-weight:600;color:var(--db-text);margin-bottom:6px">Environment</div>
|
||||
<div>Timeout: 30s</div>
|
||||
<div>Sandbox: server-side</div>
|
||||
</div>
|
||||
<button id="cs-send-agent" style="padding:10px;background:var(--db-accent,#6366f1);color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600">→ Send to Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editorHost = container.querySelector('#cs-editor');
|
||||
try {
|
||||
await loadMonaco(editorHost, firstLang);
|
||||
} catch (e) {
|
||||
console.warn('[code-studio] Monaco load failed, using fallback textarea:', e);
|
||||
mountFallback(editorHost, firstLang);
|
||||
}
|
||||
|
||||
renderHistory(container);
|
||||
pickTab(container, 'output');
|
||||
|
||||
const langSel = container.querySelector('#cs-language');
|
||||
langSel.addEventListener('change', () => {
|
||||
const lang = langSel.value;
|
||||
setEditorLanguage(lang);
|
||||
const fn = container.querySelector('#cs-filename');
|
||||
if (fn) fn.value = `script.${FILE_EXT[lang] || 'txt'}`;
|
||||
});
|
||||
|
||||
container.querySelector('#cs-run').addEventListener('click', () => runCode(container));
|
||||
container.querySelector('#cs-save').addEventListener('click', () => saveSnippet(container));
|
||||
container.querySelector('#cs-send-agent').addEventListener('click', () => sendToAgent(container));
|
||||
container.querySelector('#cs-fix').addEventListener('click', () => sendToAgent(container));
|
||||
|
||||
container.querySelectorAll('.cs-tab').forEach((b) => {
|
||||
b.addEventListener('click', () => pickTab(container, b.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
if (timerId) { clearInterval(timerId); timerId = null; }
|
||||
if (editor && typeof editor.dispose === 'function') { try { editor.dispose(); } catch (e) {} }
|
||||
editor = null;
|
||||
running = false;
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const FEATURES = [
|
||||
{ name: "channels", endpoints: [
|
||||
{ label: "List Channels", method: "GET", url: "/api/channels" },
|
||||
{ label: "Platforms", method: "GET", url: "/api/channels/platforms" },
|
||||
{ label: "Add Channel", method: "POST", url: "/api/channels", body: { platform: "telegram", name: "my-bot", enabled: true, config: { bot_token: "env:TELEGRAM_BOT_TOKEN" } } },
|
||||
]},
|
||||
{ name: "agents_crud", endpoints: [
|
||||
{ label: "All Agents", method: "GET", url: "/api/agents" },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Marketplace View — browse, inspect, install, and uninstall plugins.
|
||||
* API: /api/marketplace/plugins, /api/marketplace/search,
|
||||
* /api/marketplace/install, /api/marketplace/uninstall,
|
||||
* /api/marketplace/plugins/{id}
|
||||
*/
|
||||
import { helpBanner, pageToolbar, filterChips, searchInput } from './_helpers.js';
|
||||
import { showToast, showConfirm } from '../toast.js';
|
||||
|
||||
const CATEGORY_ICONS = { tools: '🔧', memory: '🧠', all: '📦' };
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function catIcon(cat) {
|
||||
return CATEGORY_ICONS[cat] || '🧩';
|
||||
}
|
||||
|
||||
function permissionsFor(plugin) {
|
||||
if (Array.isArray(plugin.permissions) && plugin.permissions.length) return plugin.permissions;
|
||||
const map = {
|
||||
web_search: ['network:outbound'],
|
||||
code_executor: ['process:spawn', 'filesystem:temp'],
|
||||
file_manager: ['filesystem:read', 'filesystem:write'],
|
||||
memory_plugin: ['filesystem:read', 'filesystem:write', 'network:outbound'],
|
||||
};
|
||||
return map[plugin.id] || [];
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let plugins = [];
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/plugins');
|
||||
const d = await r.json();
|
||||
plugins = d.plugins || [];
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="db-viewer"><pre>Failed to load marketplace: ' + e.message + '</pre></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const state = { query: '', category: 'all', tab: 'browse' };
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category).filter(Boolean)))];
|
||||
|
||||
function dedupe(list) {
|
||||
const seen = new Map();
|
||||
list.forEach((p) => { seen.set(p.id, { ...seen.get(p.id), ...p }); });
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const all = dedupe(plugins);
|
||||
return all.filter((p) => {
|
||||
if (state.tab === 'installed' && !p.installed) return false;
|
||||
if (state.category !== 'all' && p.category !== state.category) return false;
|
||||
if (state.query) {
|
||||
const q = state.query.toLowerCase();
|
||||
return (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function cardHtml(p) {
|
||||
const installed = !!p.installed;
|
||||
const id = esc(p.id);
|
||||
return `
|
||||
<div class="db-card" data-plugin="${id}" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;cursor:pointer">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-size:22px">${catIcon(p.category)}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600">${esc(p.name || p.id)}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
|
||||
</div>
|
||||
${installed ? '<span style="font-size:10px;font-weight:600;color:#22c55e;padding:2px 8px;border-radius:6px;background:rgba(34,197,94,.12)">Installed</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);line-height:1.5;min-height:34px">${esc(p.description || '')}</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="button" class="mkt-open" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:7px;cursor:pointer;font-size:12px">Details</button>
|
||||
${installed
|
||||
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:7px;cursor:pointer;font-size:12px">Uninstall</button>`
|
||||
: `<button type="button" class="mkt-install" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:7px;cursor:pointer;font-size:12px">Install</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const list = filtered();
|
||||
const installedCount = dedupe(plugins).filter((p) => p.installed).length;
|
||||
container.innerHTML = `
|
||||
${helpBanner({
|
||||
title: 'Marketplace',
|
||||
what: 'Browse agent plugins, review the permissions they request, then install or uninstall them.',
|
||||
howToUse: 'Search or filter by category, click <strong>Details</strong> to inspect permissions, then <strong>Install</strong>. Switch to the <strong>Installed</strong> tab to manage your active plugins.',
|
||||
tip: 'The <strong>Code Executor</strong> plugin pairs with Code Studio for sandboxed execution.',
|
||||
collapsed: true,
|
||||
})}
|
||||
${pageToolbar('Plugin Marketplace', `
|
||||
<button type="button" class="db-filter-chip mkt-tab" data-tab="browse" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'browse' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'browse' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Browse</button>
|
||||
<button type="button" class="db-filter-chip mkt-tab" data-tab="installed" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'installed' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'installed' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Installed (${installedCount})</button>
|
||||
`)}
|
||||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px">
|
||||
${searchInput('Search plugins…', 'mkt-search')}
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">${filterChips(categories, state.category, 'data-cat')}</div>
|
||||
</div>
|
||||
<div id="mkt-grid" class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
|
||||
${list.map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>'}
|
||||
</div>
|
||||
<div id="mkt-drawer"></div>`;
|
||||
|
||||
wire();
|
||||
}
|
||||
|
||||
function drawerHtml(p) {
|
||||
const perms = permissionsFor(p);
|
||||
const installed = !!p.installed;
|
||||
const id = esc(p.id);
|
||||
return `
|
||||
<div class="mkt-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:120;display:flex;justify-content:flex-end">
|
||||
<div style="width:min(480px,100%);height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);overflow-y:auto;padding:24px;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:16px">
|
||||
<span style="font-size:32px">${catIcon(p.category)}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:18px;font-weight:700">${esc(p.name || p.id)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
|
||||
</div>
|
||||
<button type="button" class="mkt-close" style="background:transparent;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer;line-height:1">×</button>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--db-text-dim);margin-bottom:20px">${esc(p.description || 'No description provided.')}</div>
|
||||
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--db-text-dim);margin-bottom:8px">Permissions</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:24px">
|
||||
${perms.length
|
||||
? perms.map((perm) => `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--db-border);border-radius:8px;font-size:12px"><span>🔐</span><code style="font-size:12px">${esc(perm)}</code></div>`).join('')
|
||||
: '<div style="font-size:12px;color:var(--db-text-dim)">No special permissions requested.</div>'}
|
||||
</div>
|
||||
${installed
|
||||
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Uninstall</button>`
|
||||
: `<button type="button" class="mkt-install" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Install ${perms.length ? '(' + perms.length + ' permissions)' : ''}</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
const p = dedupe(plugins).find((x) => x.id === id);
|
||||
if (!p) return;
|
||||
const drawer = container.querySelector('#mkt-drawer');
|
||||
drawer.innerHTML = drawerHtml(p);
|
||||
drawer.querySelector('.mkt-close').addEventListener('click', () => { drawer.innerHTML = ''; });
|
||||
drawer.querySelector('.mkt-drawer-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('mkt-drawer-overlay')) drawer.innerHTML = '';
|
||||
});
|
||||
wireActions(drawer);
|
||||
}
|
||||
|
||||
async function install(id) {
|
||||
const p = dedupe(plugins).find((x) => x.id === id);
|
||||
const perms = p ? permissionsFor(p) : [];
|
||||
const ok = await showConfirm(
|
||||
`Install ${p ? p.name || id : id}?`,
|
||||
perms.length ? 'This plugin requests: ' + perms.join(', ') : 'This plugin requests no special permissions.'
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: id }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || d.status !== 'installed') throw new Error(d.error || `HTTP ${r.status}`);
|
||||
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: true } : x));
|
||||
showToast(`Installed ${id}`, 'success');
|
||||
paint();
|
||||
} catch (e) {
|
||||
showToast('Install failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall(id) {
|
||||
const ok = await showConfirm(`Uninstall ${id}?`, 'This will remove the plugin from your installed set.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/uninstall', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: id }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || d.status !== 'uninstalled') throw new Error(d.error || `HTTP ${r.status}`);
|
||||
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: false } : x));
|
||||
showToast(`Uninstalled ${id}`, 'success');
|
||||
paint();
|
||||
} catch (e) {
|
||||
showToast('Uninstall failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function wireActions(root) {
|
||||
root.querySelectorAll('.mkt-install').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); install(b.dataset.plugin); })
|
||||
);
|
||||
root.querySelectorAll('.mkt-uninstall').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); uninstall(b.dataset.plugin); })
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const search = container.querySelector('#mkt-search');
|
||||
if (search) {
|
||||
search.value = state.query;
|
||||
search.addEventListener('input', () => {
|
||||
state.query = search.value;
|
||||
const grid = container.querySelector('#mkt-grid');
|
||||
grid.innerHTML = filtered().map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>';
|
||||
wireActions(grid);
|
||||
grid.querySelectorAll('.mkt-open').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
|
||||
);
|
||||
});
|
||||
}
|
||||
container.querySelectorAll('.mkt-tab').forEach((b) =>
|
||||
b.addEventListener('click', () => { state.tab = b.dataset.tab; paint(); })
|
||||
);
|
||||
container.querySelectorAll('.db-filter-chip[data-cat]').forEach((b) =>
|
||||
b.addEventListener('click', () => { state.category = b.dataset.cat; paint(); })
|
||||
);
|
||||
container.querySelectorAll('.mkt-open').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
|
||||
);
|
||||
container.querySelectorAll('.db-card[data-plugin]').forEach((c) =>
|
||||
c.addEventListener('click', () => openDrawer(c.dataset.plugin))
|
||||
);
|
||||
wireActions(container);
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -516,9 +516,33 @@ class TestProtocolAPI:
|
||||
|
||||
class TestMarketplaceAPI:
|
||||
def test_list_marketplace(self, client):
|
||||
r = client.get("/api/marketplace")
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_list_plugins(self, client):
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "plugins" in data
|
||||
assert isinstance(data["plugins"], list)
|
||||
|
||||
def test_install_uninstall_roundtrip(self, client):
|
||||
r = client.post("/api/marketplace/install", json={"plugin_id": "web_search"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "installed"
|
||||
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
installed = [p for p in r.json()["plugins"] if p.get("installed")]
|
||||
assert any(p["id"] == "web_search" for p in installed)
|
||||
|
||||
r = client.post("/api/marketplace/uninstall", json={"plugin_id": "web_search"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "uninstalled"
|
||||
|
||||
def test_install_unknown_plugin(self, client):
|
||||
r = client.post("/api/marketplace/install", json={"plugin_id": "does_not_exist"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Memory (from original tests, kept for completeness)
|
||||
@@ -747,6 +771,14 @@ class TestPagesAPI:
|
||||
assert r.status_code == 200
|
||||
assert "servers" in r.json()
|
||||
|
||||
def test_marketplace_page_registered(self, client):
|
||||
r = client.get("/api/pages")
|
||||
pages = r.json().get("pages", r.json())
|
||||
mkt = next((p for p in pages if p["id"] == "marketplace"), None)
|
||||
assert mkt is not None, "Marketplace page should be registered"
|
||||
assert mkt["group"] == "Agent"
|
||||
assert mkt["api_endpoint"] == "/api/marketplace/plugins"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Persistence across restart
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for channel secret redaction in API responses.
|
||||
|
||||
Regression coverage for issue #176 (recurrence of #58): ``GET /api/channels``
|
||||
and related handlers serialized the in-memory ``_channels`` dict verbatim,
|
||||
exposing ``config.bot_token`` and other secrets in the browser Network panel
|
||||
without authentication. Read handlers must redact secrets while the write path
|
||||
still accepts and uses the real token for bot startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features import channels as channels_mod
|
||||
from praisonaiui.features.channels import (
|
||||
ChannelsFeature,
|
||||
_redact_config_secrets,
|
||||
_validate_config_security,
|
||||
)
|
||||
|
||||
_DISCORD_TOKEN = "MTQ" + "a1b2c3d4e5f6g7h8i9j0k1l2"
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, path_params=None, body=None):
|
||||
self.path_params = path_params or {}
|
||||
self._body = body or {}
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_channel_state():
|
||||
saved_channels = dict(channels_mod._channels)
|
||||
saved_bots = dict(channels_mod._live_bots)
|
||||
saved_flag = ChannelsFeature._auto_started
|
||||
channels_mod._channels.clear()
|
||||
channels_mod._live_bots.clear()
|
||||
ChannelsFeature._auto_started = True
|
||||
yield
|
||||
channels_mod._channels.clear()
|
||||
channels_mod._channels.update(saved_channels)
|
||||
channels_mod._live_bots.clear()
|
||||
channels_mod._live_bots.update(saved_bots)
|
||||
ChannelsFeature._auto_started = saved_flag
|
||||
|
||||
|
||||
def _seed_channel():
|
||||
entry = {
|
||||
"id": "e4a6d6e1d0cf",
|
||||
"name": "DhivyaBot",
|
||||
"platform": "discord",
|
||||
"enabled": True,
|
||||
"running": True,
|
||||
"config": {"bot_token": _DISCORD_TOKEN},
|
||||
}
|
||||
channels_mod._channels[entry["id"]] = entry
|
||||
return entry
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
# ── Redaction helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redact_covers_suffix_secret_keys():
|
||||
out = _redact_config_secrets(
|
||||
{
|
||||
"bot_token": _DISCORD_TOKEN,
|
||||
"app_token": "xapp-1-abc",
|
||||
"signing_secret": "shh",
|
||||
"client_secret": "shh2",
|
||||
"api_key": "k",
|
||||
"webhook_url": "https://example.com/hook",
|
||||
}
|
||||
)
|
||||
assert out["bot_token"] == "***REDACTED***"
|
||||
assert out["app_token"] == "***REDACTED***"
|
||||
assert out["signing_secret"] == "***REDACTED***"
|
||||
assert out["client_secret"] == "***REDACTED***"
|
||||
assert out["api_key"] == "***REDACTED***"
|
||||
assert out["webhook_url"] == "https://example.com/hook"
|
||||
assert out["bot_token_set"] is True
|
||||
assert out["app_token_set"] is True
|
||||
|
||||
|
||||
def test_redact_preserves_env_references():
|
||||
out = _redact_config_secrets({"bot_token": "env:DISCORD_BOT_TOKEN"})
|
||||
assert out["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
assert "bot_token_set" not in out
|
||||
|
||||
|
||||
def test_redact_is_non_mutating():
|
||||
original = {"bot_token": _DISCORD_TOKEN}
|
||||
_redact_config_secrets(original)
|
||||
assert original["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
# ── Write path rejects inline secrets on suffix keys ─────────────────
|
||||
|
||||
|
||||
def test_validate_rejects_inline_suffix_secret():
|
||||
err = _validate_config_security({"app_token": "xapp-1-" + "a" * 30})
|
||||
assert err is not None
|
||||
assert "app_token" in err
|
||||
|
||||
|
||||
def test_validate_allows_env_reference_suffix_key():
|
||||
assert _validate_config_security({"signing_secret": "env:SLACK_SIGNING_SECRET"}) is None
|
||||
|
||||
|
||||
# ── Read handlers must not leak ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_redacts_bot_token():
|
||||
_seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._list(_Req())
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
cfg = body["channels"][0]["config"]
|
||||
assert cfg["bot_token"] == "***REDACTED***"
|
||||
assert cfg["bot_token_set"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._get(_Req(path_params={"channel_id": entry["id"]}))
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_response_redacts_bot_token():
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
body={
|
||||
"id": "newch",
|
||||
"name": "New",
|
||||
"platform": "discord",
|
||||
"config": {"bot_token": "env:DISCORD_BOT_TOKEN"},
|
||||
}
|
||||
)
|
||||
with patch.object(feature, "_start_channel_bot", return_value=None), patch.object(
|
||||
feature, "_sync_running_status"
|
||||
), patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._add(req)
|
||||
body = _body(resp)
|
||||
assert resp.status_code == 201
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
path_params={"channel_id": entry["id"]},
|
||||
body={"name": "Renamed"},
|
||||
)
|
||||
with patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._update(req)
|
||||
body = _body(resp)
|
||||
assert body["name"] == "Renamed"
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(path_params={"channel_id": entry["id"]})
|
||||
with patch.object(feature, "_stop_channel_bot", return_value=None):
|
||||
resp = await feature._toggle(req)
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
# ── Write path must keep the real secret internally ──────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_store_retains_real_token_after_read():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
await feature._list(_Req())
|
||||
assert channels_mod._channels[entry["id"]]["config"]["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_channel_bot_reads_unredacted_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_direct(platform, token, agent, config):
|
||||
captured["token"] = token
|
||||
return None
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=None), patch.object(
|
||||
feature, "_create_bot_direct", side_effect=_fake_direct
|
||||
):
|
||||
await feature._start_channel_bot(entry["id"], entry)
|
||||
|
||||
assert captured["token"] == _DISCORD_TOKEN
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Regression tests for issue #189.
|
||||
|
||||
The Channels dashboard view lost its CRUD controls in the 3961508 refactor,
|
||||
leaving a read-only page (list + restart only). These tests assert the
|
||||
committed frontend view source
|
||||
``src/praisonaiui/templates/frontend/plugins/views/channels.js`` restores the
|
||||
Add Channel modal and per-card actions (Enable/Disable, Restart, Test, Delete),
|
||||
aligned with the #176 secret policy (env references, no plaintext token paste
|
||||
by default) and the #172 dropdown contrast fix (``.db-form-select``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
|
||||
EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
|
||||
|
||||
|
||||
def _view() -> str:
|
||||
return CHANNELS_VIEW.read_text()
|
||||
|
||||
|
||||
class TestAddChannelControls:
|
||||
def test_add_channel_button_present(self):
|
||||
src = _view()
|
||||
assert 'id="ch-add"' in src
|
||||
assert "+ Add Channel" in src
|
||||
|
||||
def test_add_modal_has_platform_select(self):
|
||||
src = _view()
|
||||
assert 'id="chf-platform"' in src
|
||||
assert 'id="ch-modal"' in src
|
||||
|
||||
def test_platform_select_uses_dashboard_form_class(self):
|
||||
# #172 dark-theme option contrast fix applies to .db-form-select.
|
||||
src = _view()
|
||||
assert "db-form-select" in src
|
||||
|
||||
def test_save_posts_to_channels_api(self):
|
||||
src = _view()
|
||||
assert "/api/channels" in src
|
||||
assert "'POST'" in src or '"POST"' in src
|
||||
assert "JSON.stringify" in src
|
||||
|
||||
|
||||
class TestCardActions:
|
||||
def test_all_card_actions_present(self):
|
||||
src = _view()
|
||||
for cls in ("ch-toggle", "ch-restart", "ch-test", "ch-del"):
|
||||
assert cls in src, f"missing card action {cls}"
|
||||
|
||||
def test_toggle_endpoint(self):
|
||||
assert "/toggle" in _view()
|
||||
|
||||
def test_test_endpoint(self):
|
||||
assert "/test" in _view()
|
||||
|
||||
def test_delete_uses_confirm(self):
|
||||
src = _view()
|
||||
assert "showConfirm" in src
|
||||
assert "DELETE" in src
|
||||
|
||||
|
||||
class TestSecretPolicy:
|
||||
def test_secret_fields_default_to_env_reference(self):
|
||||
# Placeholders steer operators to env references per #176.
|
||||
src = _view()
|
||||
assert "env:" in src
|
||||
|
||||
def test_modal_surfaces_api_validation_error(self):
|
||||
src = _view()
|
||||
assert 'id="chf-error"' in src
|
||||
|
||||
def test_no_inline_password_prefill(self):
|
||||
# Fields must not be pre-populated with stored secrets.
|
||||
src = _view()
|
||||
assert "value=" not in src or "***REDACTED***" not in src
|
||||
|
||||
|
||||
class TestEmptyState:
|
||||
def test_empty_state_references_add_button(self):
|
||||
src = _view()
|
||||
assert "+ Add Channel" in src
|
||||
assert "Use the API or CLI to add channels." not in src
|
||||
|
||||
|
||||
class TestExplorerPreset:
|
||||
def test_explorer_has_add_channel_post(self):
|
||||
src = EXPLORER_VIEW.read_text()
|
||||
assert "Add Channel" in src
|
||||
assert "env:TELEGRAM_BOT_TOKEN" in src
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Regression tests for #177 — offline chat send produces no user-visible error.
|
||||
|
||||
The browser E2E audit simulated offline mode on /chat and found no visible
|
||||
error feedback. These source-parsing tests assert the fixes:
|
||||
|
||||
RC-2 selectPage shows a shell error (.db-error / role=alert via
|
||||
showDashboardInitError) when #db-main-content is missing instead
|
||||
of returning silently.
|
||||
RC-3 the HTTP fallback catch path surfaces an offline hint plus a toast.
|
||||
RC-3b system error bubbles (prefixed with ❌) get role="alert" for a11y.
|
||||
RC-4 ws.send() is wrapped in try/catch with a user-visible error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
|
||||
class TestSelectPageShellError:
|
||||
def test_shell_error_helper_defined(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
assert "function showDashboardInitError(" in src
|
||||
assert "'alert'" in src
|
||||
assert "db-error" in src
|
||||
|
||||
def test_select_page_calls_shell_error_on_missing_main(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
idx = src.find("const main = document.getElementById('db-main-content')")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 400]
|
||||
assert "if (!main)" in region
|
||||
assert "showDashboardInitError(" in region
|
||||
|
||||
|
||||
class TestChatOfflineMessaging:
|
||||
def test_toast_imported(self):
|
||||
src = CHAT_JS.read_text()
|
||||
assert "import { showToast } from '../toast.js';" in src
|
||||
|
||||
def test_offline_hint_in_fallback(self):
|
||||
src = CHAT_JS.read_text()
|
||||
assert "navigator.onLine === false" in src
|
||||
assert "You are offline. " in src
|
||||
|
||||
def test_fallback_catch_shows_toast(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("fetch('/api/chat/send'")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 900]
|
||||
assert ".catch(" in region
|
||||
assert "showToast(" in region
|
||||
assert "appendMessage('system'" in region
|
||||
assert "setStatus('error')" in region
|
||||
|
||||
|
||||
class TestSystemErrorRoleAlert:
|
||||
def test_append_message_sets_role_alert(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("function appendMessage(")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 700]
|
||||
assert "setAttribute('role', 'alert')" in region
|
||||
assert "startsWith('❌')" in region
|
||||
|
||||
|
||||
class TestWebSocketSendGuard:
|
||||
def test_ws_send_wrapped_in_try_catch(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("ws.send(JSON.stringify(payload))")
|
||||
assert idx != -1
|
||||
before = src[max(0, idx - 120):idx]
|
||||
after = src[idx:idx + 300]
|
||||
assert "try {" in before
|
||||
assert "catch" in after
|
||||
assert "Connection lost. Message not sent." in after
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the Code Studio view (STITCH-006)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
VIEWS = Path("src/praisonaiui/templates/frontend/plugins/views")
|
||||
PLUGINS = Path("src/praisonaiui/templates/frontend/plugins")
|
||||
|
||||
|
||||
class TestCodeStudioPage:
|
||||
def test_page_registered(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.get("/api/pages")
|
||||
assert r.status_code == 200
|
||||
ids = {p["id"] for p in r.json()["pages"]}
|
||||
assert "code-studio" in ids
|
||||
|
||||
def test_page_metadata(self):
|
||||
client = TestClient(create_app())
|
||||
pages = {p["id"]: p for p in client.get("/api/pages").json()["pages"]}
|
||||
page = pages["code-studio"]
|
||||
assert page["title"] == "Code Studio"
|
||||
assert page["api_endpoint"] == "/api/code/languages"
|
||||
|
||||
|
||||
class TestCodeExecutionApi:
|
||||
def test_languages(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.get("/api/code/languages")
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "languages" in d and "count" in d
|
||||
|
||||
def test_execute(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.post(
|
||||
"/api/code/execute",
|
||||
json={"code": "print('hi')", "language": "python", "timeout": 30},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "status" in d and "language" in d
|
||||
|
||||
|
||||
class TestCodeStudioFrontend:
|
||||
def test_view_file_exists(self):
|
||||
assert (VIEWS / "code-studio.js").is_file()
|
||||
|
||||
def test_view_exports_render(self):
|
||||
src = (VIEWS / "code-studio.js").read_text()
|
||||
assert "export async function render" in src
|
||||
assert "export function cleanup" in src
|
||||
|
||||
def test_view_calls_execute_api(self):
|
||||
src = (VIEWS / "code-studio.js").read_text()
|
||||
assert "/api/code/execute" in src
|
||||
assert "/api/code/languages" in src
|
||||
|
||||
def test_no_browser_eval(self):
|
||||
src = (VIEWS / "code-studio.js").read_text()
|
||||
assert "eval(" not in src
|
||||
|
||||
def test_registered_in_dashboard(self):
|
||||
src = (PLUGINS / "dashboard.js").read_text()
|
||||
assert "'code-studio'" in src
|
||||
assert "code-studio.js" in src
|
||||
|
||||
def test_send_to_agent_event(self):
|
||||
src = (VIEWS / "code-studio.js").read_text()
|
||||
assert "aiui:prefill-composer" in src
|
||||
|
||||
def test_chat_has_prefill_listener(self):
|
||||
src = (VIEWS / "chat.js").read_text()
|
||||
assert "aiui:prefill-composer" in src
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression tests for #178 — Ctrl+K command palette exposure and idempotency.
|
||||
|
||||
The dashboard command palette (initCommandPalette / openCommandPalette /
|
||||
.db-cmdk-overlay) is implemented in the shipped bundle, but the acceptance
|
||||
criteria require openCommandPalette to be reachable from the global scope
|
||||
(``typeof openCommandPalette === 'function'``) and require that repeated
|
||||
initialisation (HMR / full reload) does not double-register the keydown
|
||||
handler. These source-parsing tests mirror ``test_dashboard_shell_order.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
|
||||
class TestCommandPalette:
|
||||
def _src(self) -> str:
|
||||
return DASHBOARD_JS.read_text()
|
||||
|
||||
def test_open_command_palette_exposed_globally(self):
|
||||
src = self._src()
|
||||
assert "window.openCommandPalette = openCommandPalette" in src, (
|
||||
"openCommandPalette must be exposed on window for the global "
|
||||
"'typeof openCommandPalette === function' acceptance criterion"
|
||||
)
|
||||
|
||||
def test_open_command_palette_on_aiui_namespace(self):
|
||||
src = self._src()
|
||||
assert "window.aiui.openCommandPalette = openCommandPalette" in src
|
||||
|
||||
def test_init_command_palette_guarded_against_double_register(self):
|
||||
src = self._src()
|
||||
m = re.search(
|
||||
r"function initCommandPalette\([^)]*\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initCommandPalette not found"
|
||||
body = m.group(1)
|
||||
assert "_paletteInitialized" in body, (
|
||||
"initCommandPalette must guard against duplicate registration"
|
||||
)
|
||||
assert "return" in body, (
|
||||
"initCommandPalette must early-return when already initialised"
|
||||
)
|
||||
|
||||
def test_init_command_palette_called_after_build(self):
|
||||
src = self._src()
|
||||
build_idx = src.find("await buildDashboard()")
|
||||
init_idx = src.find("initCommandPalette();")
|
||||
assert build_idx != -1, "buildDashboard call not found"
|
||||
assert init_idx != -1, "initCommandPalette call not found"
|
||||
assert build_idx < init_idx, (
|
||||
"initCommandPalette must run after buildDashboard so the shell exists"
|
||||
)
|
||||
|
||||
def test_ctrl_and_meta_k_bound(self):
|
||||
src = self._src()
|
||||
assert re.search(
|
||||
r"e\.ctrlKey \|\| e\.metaKey", src
|
||||
), "Ctrl+K / Cmd+K must both open the palette"
|
||||
@@ -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,77 @@
|
||||
"""Regression tests for issue #172.
|
||||
|
||||
Two dark-theme defects on the dashboard shell:
|
||||
|
||||
1. Native ``<select>`` option popups inherited the dashboard's light
|
||||
foreground colour while the browser painted a white system background,
|
||||
producing unreadable white-on-white platform labels (WCAG AA failure).
|
||||
|
||||
2. The sidebar header brand text could overlap an adjacent control because
|
||||
the long title had no overflow/ellipsis guard and the absolutely
|
||||
positioned toggle reserved no space.
|
||||
|
||||
Both fixes live in the committed dashboard bundle
|
||||
``src/praisonaiui/templates/frontend/plugins/dashboard.js`` (``DASHBOARD_STYLE``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
def _style() -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
|
||||
assert m, "DASHBOARD_STYLE template literal not found"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
class TestSelectOptionContrast:
|
||||
"""Native option popups must use a dark surface with light text."""
|
||||
|
||||
def test_form_select_option_has_explicit_colors(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-form-select option[^{]*\{([^}]*)\}", style)
|
||||
assert m, "no explicit styling for .db-form-select option"
|
||||
body = m.group(1)
|
||||
assert "background-color" in body
|
||||
assert "color" in body
|
||||
assert "var(--db-text)" in body
|
||||
|
||||
def test_generic_dashboard_select_option_styled(self):
|
||||
style = _style()
|
||||
assert ".db-active select option" in style or "#root select option" in style
|
||||
|
||||
def test_selected_option_is_highlighted(self):
|
||||
style = _style()
|
||||
assert "option:checked" in style
|
||||
|
||||
|
||||
class TestSidebarHeaderNoOverlap:
|
||||
"""Sidebar brand text must not overlap adjacent controls."""
|
||||
|
||||
def test_header_reserves_space_and_truncates(self):
|
||||
style = _style()
|
||||
m = re.search(r"\n \.db-sidebar-header\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header base rule not found"
|
||||
body = m.group(1)
|
||||
assert "min-width: 0" in body or "min-width:0" in body
|
||||
assert "padding-right" in body
|
||||
|
||||
def test_header_label_has_ellipsis(self):
|
||||
style = _style()
|
||||
assert "text-overflow: ellipsis" in style
|
||||
assert "white-space: nowrap" in style
|
||||
|
||||
def test_logo_does_not_shrink(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-sidebar-header \.logo\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header .logo rule not found"
|
||||
assert "flex-shrink: 0" in m.group(1)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for #175 — selectPage() silent abort when #db-main-content missing.
|
||||
|
||||
The dashboard style (`dashboard.js`) skips React. When the shell layout is
|
||||
incomplete and `#db-main-content` is absent, `selectPage()` used to return
|
||||
silently, leaving every view empty with no console error or user-visible signal.
|
||||
These source-parsing tests assert the defensive guard and error surface are
|
||||
wired in, mirroring `test_session_search_dashboard.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
|
||||
class TestSelectPageGuard:
|
||||
def _select_page_body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(
|
||||
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "selectPage not found"
|
||||
return m.group(1)
|
||||
|
||||
def test_missing_main_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
guard = m.group(1)
|
||||
assert "console.error" in guard
|
||||
assert "db-main-content" in guard
|
||||
|
||||
def test_missing_main_surfaces_init_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
assert "showDashboardInitError" in m.group(1)
|
||||
|
||||
def test_missing_page_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!page\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing page not found"
|
||||
assert "console.error" in m.group(1)
|
||||
|
||||
def test_show_dashboard_init_error_helper_present(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(
|
||||
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "showDashboardInitError helper not found"
|
||||
helper = m.group(1)
|
||||
assert "db-init-error" in helper
|
||||
assert "role" in helper and "alert" in helper
|
||||
|
||||
def test_no_silent_combined_return(self):
|
||||
body = self._select_page_body()
|
||||
assert "if (!main || !page) return;" not in body
|
||||
@@ -13,11 +13,12 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
|
||||
|
||||
|
||||
class TestDashboardShellOrder:
|
||||
@@ -48,3 +49,17 @@ class TestDashboardShellOrder:
|
||||
body,
|
||||
)
|
||||
assert m, "shellHeader insert must be guarded by children.length"
|
||||
|
||||
def test_generator_emits_main_before_insert_before(self):
|
||||
src = GENERATOR.read_text()
|
||||
append_idx = src.find("root.appendChild(main);\n const shellHeader")
|
||||
insert_idx = src.find("root.insertBefore(shellHeader, main)")
|
||||
assert append_idx != -1, (
|
||||
"generator must emit root.appendChild(main) before shellHeader"
|
||||
)
|
||||
assert insert_idx != -1, (
|
||||
"generator must emit root.insertBefore(shellHeader, main)"
|
||||
)
|
||||
assert append_idx < insert_idx, (
|
||||
"generator must append main to root before insertBefore references it"
|
||||
)
|
||||
@@ -90,9 +90,10 @@ class TestSessionLifecycle:
|
||||
assert "openai package not installed" in session_info["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lifecycle_with_mocked_openai(self):
|
||||
async def test_session_lifecycle_with_mocked_openai(self, monkeypatch):
|
||||
"""Test full session lifecycle with mocked OpenAI."""
|
||||
import sys
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
manager = OpenAIRealtimeManager()
|
||||
|
||||
# Mock OpenAI client and response
|
||||
@@ -140,6 +141,27 @@ class TestSessionLifecycle:
|
||||
# Verify session is removed from internal tracking
|
||||
assert session_id not in manager._sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_yields_session_ready_with_api_key(self, monkeypatch):
|
||||
"""With OPENAI_API_KEY set, receive_audio yields session.ready first."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
manager = OpenAIRealtimeManager()
|
||||
session_id = "session_ready_test"
|
||||
manager._sessions[session_id] = {
|
||||
"status": "created",
|
||||
"client_secret": "secret_abc",
|
||||
}
|
||||
|
||||
events = []
|
||||
async for event in manager.receive_audio(session_id):
|
||||
events.append(event)
|
||||
break
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "session.ready"
|
||||
assert events[0]["provider"] == "openai"
|
||||
assert events[0]["client_secret"] == "secret_abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_invalid_session(self):
|
||||
"""Test receive_audio with invalid session ID."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for `aiui run` propagating --port/--host to gateway main()."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonaiui.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _write_gateway_app(tmp_path):
|
||||
app_file = tmp_path / "app.py"
|
||||
app_file.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
|
||||
|
||||
async def main():
|
||||
pass
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return app_file
|
||||
|
||||
|
||||
def _invoke(tmp_path):
|
||||
app_file = _write_gateway_app(tmp_path)
|
||||
captured = {}
|
||||
|
||||
def fake_run(coro):
|
||||
coro.close()
|
||||
captured["PORT"] = os.environ.get("PORT")
|
||||
captured["HOST"] = os.environ.get("HOST")
|
||||
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_socket.return_value.__enter__.return_value.bind.return_value = None
|
||||
with patch(
|
||||
"praisonaiui.integration.AIUIGateway", return_value=MagicMock()
|
||||
), patch(
|
||||
"praisonaiui.integration.check_praisonai_available", return_value=True
|
||||
), patch("asyncio.run", side_effect=fake_run):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["run", str(app_file), "--backend", "praisonai", "--port", "8101"],
|
||||
)
|
||||
return result, captured
|
||||
|
||||
|
||||
def test_run_sets_port_env_before_gateway_main(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("PORT", raising=False)
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "8101"
|
||||
assert captured["HOST"] == "127.0.0.1"
|
||||
|
||||
|
||||
def test_explicit_port_env_overrides_cli_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("PORT", "9000")
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "9000"
|
||||
assert os.environ["PORT"] == "9000"
|
||||
@@ -3,11 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _praisonai_jobs_available() -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec("praisonai.jobs") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_jobs_client():
|
||||
import praisonaiui.server as server
|
||||
|
||||
@@ -31,6 +31,9 @@ class TestDashboardSessionSearch:
|
||||
# keydown listener physically lives.
|
||||
src = self._src()
|
||||
assert "initSessionSearch" in src
|
||||
# Ctrl+K / Cmd+K ownership lives in the command palette
|
||||
# (initCommandPalette), which exposes a "Search sessions…" entry that
|
||||
# calls openSessionSearch. initSessionSearch only owns Escape-to-close.
|
||||
m = re.search(
|
||||
r"function initCommandPalette\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
@@ -45,6 +48,16 @@ class TestDashboardSessionSearch:
|
||||
assert "openSessionSearch()" in src
|
||||
assert "action: 'sessions'" in src or 'action: "sessions"' in src
|
||||
|
||||
# initSessionSearch owns only Escape-to-close for the session palette.
|
||||
m = re.search(
|
||||
r"function initSessionSearch\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initSessionSearch not found"
|
||||
assert "Escape" in m.group(1), (
|
||||
"initSessionSearch must own Escape-to-close for the session palette"
|
||||
)
|
||||
|
||||
def test_palette_fetches_sessions_endpoint(self):
|
||||
src = self._src()
|
||||
m = re.search(
|
||||
|
||||
@@ -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