Replace Gradio UI with HTMX + FastAPI frontend

New lightweight web UI with dark theme, SSE-streamed chat, and live
dashboard. Gradio moved to /old for transition. Adds jinja2,
python-multipart, sse-starlette deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
PeninsulaInd 2026-03-25 15:30:03 -05:00
parent 6e7e2b2320
commit 74d20a406a
17 changed files with 1916 additions and 26 deletions

View File

@ -187,19 +187,8 @@ def main():
except Exception as e:
log.warning("Scheduler not available: %s", e)
log.info("Launching Gradio UI on %s:%s...", config.host, config.port)
blocks = create_ui(
registry, config, default_llm, notification_bus=notification_bus, scheduler=scheduler
)
# Build a parent FastAPI app so we can mount the dashboard alongside Gradio.
# Inserting routes into blocks.app before launch() doesn't work because
# launch()/mount_gradio_app() replaces the internal App instance.
import gradio as gr
import uvicorn
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
fastapi_app = FastAPI()
@ -210,24 +199,33 @@ def main():
fastapi_app.include_router(api_router)
log.info("API router mounted at /api/")
# Mount the dashboard as static files (must come before Gradio's catch-all)
dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard"
if dashboard_dir.is_dir():
# Redirect /dashboard (no trailing slash) → /dashboard/
@fastapi_app.get("/dashboard")
async def _dashboard_redirect():
return RedirectResponse(url="/dashboard/")
# Mount new HTMX web UI (chat at /, dashboard at /dashboard)
from .web import mount_web_app
fastapi_app.mount(
"/dashboard",
StaticFiles(directory=str(dashboard_dir), html=True),
name="dashboard",
mount_web_app(
fastapi_app,
registry,
config,
default_llm,
notification_bus=notification_bus,
scheduler=scheduler,
db=db,
)
# Mount Gradio at /old for transition period
try:
import gradio as gr
log.info("Mounting Gradio UI at /old...")
blocks = create_ui(
registry, config, default_llm, notification_bus=notification_bus, scheduler=scheduler
)
log.info("Dashboard mounted at /dashboard/ (serving %s)", dashboard_dir)
# Mount Gradio at the root
gr.mount_gradio_app(fastapi_app, blocks, path="/", pwa=True, show_error=True)
gr.mount_gradio_app(fastapi_app, blocks, path="/old", pwa=False, show_error=True)
log.info("Gradio UI available at /old")
except Exception as e:
log.warning("Gradio UI not available: %s", e)
log.info("Launching web UI on %s:%s...", config.host, config.port)
uvicorn.run(fastapi_app, host=config.host, port=config.port)

View File

@ -0,0 +1,619 @@
/* CheddahBot Dark Theme */
:root {
--bg-primary: #0d1117;
--bg-surface: #161b22;
--bg-surface-hover: #1c2129;
--bg-input: #0d1117;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--accent: #2dd4bf;
--accent-dim: #134e4a;
--border: #30363d;
--success: #3fb950;
--error: #f85149;
--warning: #d29922;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--sidebar-width: 280px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
overflow: hidden;
}
/* Top Navigation */
.top-nav {
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: 48px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.nav-brand {
font-weight: 700;
font-size: 1.1em;
color: var(--accent);
}
.nav-links { display: flex; gap: 4px; }
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 6px 14px;
border-radius: var(--radius);
font-size: 0.9em;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
.nav-link.active { color: var(--accent); background: var(--accent-dim); }
/* Main content area */
.main-content {
height: calc(100vh - 48px);
overflow: hidden;
}
/* ─── Chat Layout ─── */
.chat-layout {
display: flex;
height: 100%;
}
/* Sidebar */
.chat-sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 12px;
gap: 8px;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 { font-size: 0.85em; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.sidebar-toggle {
display: none;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.2em;
cursor: pointer;
}
.sidebar-open-btn {
display: none;
position: fixed;
top: 56px;
left: 8px;
z-index: 20;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 10px;
border-radius: var(--radius);
cursor: pointer;
font-size: 1.2em;
}
.sidebar-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.agent-selector { display: flex; flex-direction: column; gap: 4px; }
.agent-btn {
padding: 8px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 0.9em;
transition: border-color 0.15s, background 0.15s;
}
.agent-btn:hover { background: var(--bg-surface-hover); }
.agent-btn.active { border-color: var(--accent); background: var(--accent-dim); }
.btn-new-chat {
width: 100%;
padding: 8px;
background: var(--accent-dim);
border: 1px solid var(--accent);
border-radius: var(--radius);
color: var(--accent);
cursor: pointer;
font-size: 0.9em;
transition: background 0.15s;
}
.btn-new-chat:hover { background: var(--accent); color: var(--bg-primary); }
.chat-sidebar h3 {
font-size: 0.8em;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 8px;
}
.conv-btn {
display: block;
width: 100%;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.15s;
}
.conv-btn:hover { background: var(--bg-surface-hover); }
.conv-btn.active { border-color: var(--accent); background: var(--accent-dim); }
/* Chat main area */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* Status bar */
.status-bar {
display: flex;
gap: 16px;
padding: 8px 20px;
font-size: 0.8em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.status-item strong { color: var(--text-primary); }
.text-ok { color: var(--success) !important; }
.text-err { color: var(--error) !important; }
/* Notification banner */
.notification-banner {
margin: 8px 20px 0;
padding: 10px 16px;
background: var(--bg-surface);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
font-size: 0.9em;
color: var(--accent);
}
/* Messages area */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
display: flex;
gap: 10px;
max-width: 85%;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; }
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
font-weight: 700;
flex-shrink: 0;
}
.message.user .message-avatar { background: var(--accent-dim); color: var(--accent); }
.message.assistant .message-avatar { background: #1c2129; color: var(--text-secondary); }
.message-body {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
min-width: 0;
}
.message.user .message-body { background: var(--accent-dim); border-color: var(--accent); }
.message-content {
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Markdown rendering in messages */
.message-content p { margin: 0.4em 0; }
.message-content p:first-child { margin-top: 0; }
.message-content p:last-child { margin-bottom: 0; }
.message-content pre {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.9em;
margin: 0.5em 0;
}
.message-content code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--bg-primary);
padding: 2px 5px;
border-radius: 3px;
}
.message-content pre code { background: none; padding: 0; }
.message-content ul, .message-content ol { margin: 0.4em 0; padding-left: 1.5em; }
.message-content a { color: var(--accent); }
.message-content blockquote {
border-left: 3px solid var(--accent);
padding-left: 12px;
color: var(--text-secondary);
margin: 0.5em 0;
}
.message-content table { border-collapse: collapse; margin: 0.5em 0; }
.message-content th, .message-content td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.message-content th { background: var(--bg-surface-hover); }
/* Chat input area */
.chat-input-area {
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
#chat-input {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 15px;
resize: none;
max-height: 200px;
line-height: 1.4;
}
#chat-input:focus { outline: none; border-color: var(--accent); }
#chat-input::placeholder { color: var(--text-muted); }
.file-upload-btn {
padding: 8px 10px;
cursor: pointer;
font-size: 1.2em;
color: var(--text-secondary);
transition: color 0.15s;
flex-shrink: 0;
}
.file-upload-btn:hover { color: var(--accent); }
.send-btn {
padding: 8px 14px;
background: var(--accent);
border: none;
border-radius: var(--radius);
color: var(--bg-primary);
font-size: 1.1em;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.send-btn:hover { opacity: 0.85; }
.file-preview {
margin-top: 6px;
font-size: 0.85em;
color: var(--text-secondary);
}
.file-preview .file-tag {
display: inline-block;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
margin-right: 6px;
}
/* ─── Dashboard Layout ─── */
.dashboard-layout {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 20px;
height: 100%;
overflow-y: auto;
}
.panel {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.panel-title {
font-size: 1.1em;
font-weight: 600;
margin-bottom: 12px;
color: var(--accent);
}
.panel-section {
margin-bottom: 16px;
}
.panel-section h3 {
font-size: 0.85em;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
/* Loop health grid */
.loop-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.loop-badge {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 12px;
border-radius: var(--radius);
font-size: 0.8em;
min-width: 90px;
border: 1px solid var(--border);
}
.loop-name { font-weight: 600; }
.loop-ago { color: var(--text-secondary); font-size: 0.85em; }
.badge-ok { border-color: var(--success); background: rgba(63, 185, 80, 0.1); }
.badge-ok .loop-name { color: var(--success); }
.badge-warn { border-color: var(--warning); background: rgba(210, 153, 34, 0.1); }
.badge-warn .loop-name { color: var(--warning); }
.badge-err { border-color: var(--error); background: rgba(248, 81, 73, 0.1); }
.badge-err .loop-name { color: var(--error); }
.badge-muted { border-color: var(--text-muted); }
.badge-muted .loop-name { color: var(--text-muted); }
/* Active executions */
.exec-list { display: flex; flex-direction: column; gap: 6px; }
.exec-item {
display: flex;
gap: 12px;
padding: 6px 10px;
background: var(--bg-primary);
border-radius: 4px;
font-size: 0.85em;
}
.exec-name { flex: 1; font-weight: 500; }
.exec-tool { color: var(--text-secondary); }
.exec-dur { color: var(--accent); font-family: var(--font-mono); }
/* Action buttons */
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
padding: 8px 16px;
background: var(--bg-surface-hover);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
cursor: pointer;
font-size: 0.9em;
transition: border-color 0.15s, background 0.15s;
}
.btn:hover { border-color: var(--accent); }
.btn-sm { padding: 6px 12px; font-size: 0.8em; }
/* Notification feed */
.notif-feed { display: flex; flex-direction: column; gap: 4px; max-height: 300px; overflow-y: auto; }
.notif-item {
padding: 6px 10px;
font-size: 0.85em;
border-left: 3px solid var(--border);
background: var(--bg-primary);
border-radius: 0 4px 4px 0;
}
.notif-clickup { border-left-color: var(--accent); }
.notif-info { border-left-color: var(--text-secondary); }
.notif-error { border-left-color: var(--error); }
.notif-cat {
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* Task table */
.task-table { width: 100%; border-collapse: collapse; font-size: 0.85em; }
.task-table th, .task-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); text-align: left; }
.task-table th { color: var(--text-secondary); font-weight: 600; text-transform: uppercase; font-size: 0.85em; }
.task-table a { color: var(--accent); text-decoration: none; }
.task-table a:hover { text-decoration: underline; }
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.status-to-do { background: rgba(139, 148, 158, 0.2); color: var(--text-secondary); }
.status-in-progress, .status-automation-underway { background: rgba(45, 212, 191, 0.15); color: var(--accent); }
.status-error { background: rgba(248, 81, 73, 0.15); color: var(--error); }
.status-complete, .status-closed { background: rgba(63, 185, 80, 0.15); color: var(--success); }
.status-internal-review, .status-outline-review { background: rgba(210, 153, 34, 0.15); color: var(--warning); }
/* Pipeline groups */
.pipeline-group { margin-bottom: 16px; }
.pipeline-group h4 {
font-size: 0.9em;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.pipeline-stats {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.pipeline-stat {
padding: 8px 14px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
text-align: center;
}
.pipeline-stat .stat-count { font-size: 1.5em; font-weight: 700; color: var(--accent); }
.pipeline-stat .stat-label { font-size: 0.75em; color: var(--text-secondary); }
/* Flash messages */
.flash-msg {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--accent);
color: var(--bg-primary);
padding: 10px 20px;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9em;
z-index: 100;
animation: fadeIn 0.2s ease-out, fadeOut 0.5s 2.5s ease-out forwards;
}
@keyframes fadeOut { to { opacity: 0; transform: translateY(10px); } }
/* Utility */
.text-muted { color: var(--text-muted); }
/* Typing indicator */
.typing-indicator span {
display: inline-block;
width: 6px;
height: 6px;
background: var(--text-secondary);
border-radius: 50%;
margin: 0 2px;
animation: bounce 1.2s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
/* ─── Mobile ─── */
@media (max-width: 768px) {
.chat-sidebar {
position: fixed;
top: 48px;
left: 0;
bottom: 0;
z-index: 30;
transform: translateX(-100%);
transition: transform 0.2s ease;
width: 280px;
}
.chat-sidebar.open { transform: translateX(0); }
.sidebar-toggle { display: block; }
.sidebar-open-btn { display: block; }
.status-bar { flex-wrap: wrap; gap: 8px; padding: 6px 12px; font-size: 0.75em; }
.chat-messages { padding: 12px; }
.message { max-width: 95%; }
.chat-input-area { padding: 8px 12px; }
#chat-input { font-size: 16px; } /* Prevent iOS zoom */
.dashboard-layout { padding: 12px; }
.loop-grid { gap: 6px; }
.loop-badge { min-width: 70px; padding: 6px 8px; font-size: 0.75em; }
}
/* Overlay for mobile sidebar */
.sidebar-overlay {
display: none;
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 25;
}
.sidebar-overlay.visible { display: block; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }

View File

@ -0,0 +1,284 @@
/* CheddahBot Frontend JS */
// ── Session Management ──
const SESSION_KEY = 'cheddahbot_session';
function getSession() {
try { return JSON.parse(localStorage.getItem(SESSION_KEY) || '{}'); }
catch { return {}; }
}
function saveSession(data) {
const s = getSession();
Object.assign(s, data);
localStorage.setItem(SESSION_KEY, JSON.stringify(s));
}
function getActiveAgent() {
return getSession().agent_name || document.getElementById('input-agent-name')?.value || 'default';
}
// ── Agent Switching ──
function switchAgent(name) {
// Update UI
document.querySelectorAll('.agent-btn').forEach(b => {
b.classList.toggle('active', b.dataset.agent === name);
});
document.getElementById('input-agent-name').value = name;
document.getElementById('input-conv-id').value = '';
saveSession({ agent_name: name, conv_id: null });
// Clear chat and load new sidebar
document.getElementById('chat-messages').innerHTML = '';
refreshSidebar();
}
function setActiveAgent(name) {
document.querySelectorAll('.agent-btn').forEach(b => {
b.classList.toggle('active', b.dataset.agent === name);
});
const agentInput = document.getElementById('input-agent-name');
if (agentInput) agentInput.value = name;
}
// ── Sidebar ──
function refreshSidebar() {
const agent = getActiveAgent();
htmx.ajax('GET', '/chat/conversations?agent_name=' + agent, {
target: '#sidebar-conversations',
swap: 'innerHTML'
});
}
// ── Conversation Loading ──
function loadConversation(convId) {
const agent = getActiveAgent();
document.getElementById('input-conv-id').value = convId;
saveSession({ conv_id: convId });
htmx.ajax('GET', '/chat/load/' + convId + '?agent_name=' + agent, {
target: '#chat-messages',
swap: 'innerHTML'
}).then(() => {
scrollChat();
renderAllMarkdown();
});
}
// ── Chat Input ──
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
document.getElementById('chat-form').requestSubmit();
}
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
function afterSend(event) {
const input = document.getElementById('chat-input');
input.value = '';
input.style.height = 'auto';
// Clear file input and preview
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) fileInput.value = '';
const preview = document.getElementById('file-preview');
if (preview) { preview.style.display = 'none'; preview.innerHTML = ''; }
scrollChat();
}
function scrollChat() {
const el = document.getElementById('chat-messages');
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}
// ── File Upload Preview ──
function showFileNames(input) {
const preview = document.getElementById('file-preview');
if (!input.files.length) {
preview.style.display = 'none';
return;
}
let html = '';
for (const f of input.files) {
html += '<span class="file-tag">' + f.name + '</span>';
}
preview.innerHTML = html;
preview.style.display = 'block';
}
// Drag and drop
document.addEventListener('DOMContentLoaded', () => {
const chatMain = document.querySelector('.chat-main');
if (!chatMain) return;
chatMain.addEventListener('dragover', e => {
e.preventDefault();
chatMain.style.outline = '2px dashed var(--accent)';
});
chatMain.addEventListener('dragleave', () => {
chatMain.style.outline = '';
});
chatMain.addEventListener('drop', e => {
e.preventDefault();
chatMain.style.outline = '';
const fileInput = document.querySelector('input[type="file"]');
if (fileInput && e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
showFileNames(fileInput);
}
});
});
// ── SSE Streaming ──
// Handle SSE chunks for chat streaming
let streamBuffer = '';
let activeSSE = null;
document.addEventListener('htmx:sseBeforeMessage', function(e) {
// This fires for each SSE event received by htmx
});
// Watch for SSE trigger divs being added to the DOM
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.id === 'sse-trigger') {
setupStream(node);
}
}
}
});
document.addEventListener('DOMContentLoaded', () => {
const chatMessages = document.getElementById('chat-messages');
if (chatMessages) {
observer.observe(chatMessages, { childList: true, subtree: true });
}
});
function setupStream(triggerDiv) {
const sseUrl = triggerDiv.getAttribute('sse-connect');
if (!sseUrl) return;
// Remove the htmx SSE to manage manually
triggerDiv.remove();
const responseDiv = document.getElementById('assistant-response');
if (!responseDiv) return;
streamBuffer = '';
// Show typing indicator
responseDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
const source = new EventSource(sseUrl);
activeSSE = source;
source.addEventListener('chunk', function(e) {
if (streamBuffer === '') {
// Remove typing indicator on first chunk
responseDiv.innerHTML = '';
}
streamBuffer += e.data;
// Render markdown
try {
responseDiv.innerHTML = marked.parse(streamBuffer);
} catch {
responseDiv.textContent = streamBuffer;
}
scrollChat();
});
source.addEventListener('done', function(e) {
source.close();
activeSSE = null;
// Final markdown render
if (streamBuffer) {
try {
responseDiv.innerHTML = marked.parse(streamBuffer);
} catch {
responseDiv.textContent = streamBuffer;
}
}
streamBuffer = '';
// Update conv_id from done event data
const convId = e.data;
if (convId) {
document.getElementById('input-conv-id').value = convId;
saveSession({ conv_id: convId });
}
// Refresh sidebar
refreshSidebar();
scrollChat();
});
source.onerror = function() {
source.close();
activeSSE = null;
if (!streamBuffer) {
responseDiv.innerHTML = '<span class="text-err">Connection lost</span>';
}
};
}
// ── Markdown Rendering ──
function renderAllMarkdown() {
document.querySelectorAll('.message-content').forEach(el => {
const raw = el.textContent;
if (raw && typeof marked !== 'undefined') {
try {
el.innerHTML = marked.parse(raw);
} catch { /* keep raw text */ }
}
});
}
// ── Mobile Sidebar ──
function toggleSidebar() {
const sidebar = document.getElementById('chat-sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (sidebar) {
sidebar.classList.toggle('open');
}
if (overlay) {
overlay.classList.toggle('visible');
}
}
// ── Notification Banner (chat page) ──
function setupChatNotifications() {
const banner = document.getElementById('notification-banner');
if (!banner) return;
const source = new EventSource('/sse/notifications');
source.addEventListener('notification', function(e) {
const notif = JSON.parse(e.data);
banner.textContent = notif.message;
banner.style.display = 'block';
// Auto-hide after 15s
setTimeout(() => { banner.style.display = 'none'; }, 15000);
});
}
document.addEventListener('DOMContentLoaded', setupChatNotifications);
// ── HTMX Events ──
document.addEventListener('scrollChat', scrollChat);
document.addEventListener('htmx:afterSwap', function(e) {
if (e.target.id === 'chat-messages') {
renderAllMarkdown();
scrollChat();
}
});

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CheddahBot{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.3.0/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
{% block head %}{% endblock %}
</head>
<body>
<nav class="top-nav">
<div class="nav-brand">CheddahBot</div>
<div class="nav-links">
<a href="/" class="nav-link {% block nav_chat_active %}{% endblock %}">Chat</a>
<a href="/dashboard" class="nav-link {% block nav_dash_active %}{% endblock %}">Dashboard</a>
</div>
</nav>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Chat - CheddahBot{% endblock %}
{% block nav_chat_active %}active{% endblock %}
{% block content %}
<div class="chat-layout">
<!-- Sidebar -->
<aside class="chat-sidebar" id="chat-sidebar">
<div class="sidebar-header">
<h3>Agents</h3>
<button class="sidebar-toggle" onclick="toggleSidebar()" aria-label="Close sidebar">&#x2715;</button>
</div>
<div class="agent-selector" id="agent-selector">
{% for agent in agents %}
<button
class="agent-btn {% if agent.name == default_agent %}active{% endif %}"
data-agent="{{ agent.name }}"
onclick="switchAgent('{{ agent.name }}')"
>{{ agent.display_name }}</button>
{% endfor %}
</div>
<div class="sidebar-divider"></div>
<button
class="btn btn-new-chat"
hx-post="/chat/new"
hx-vals='{"agent_name": "{{ default_agent }}"}'
hx-target="#chat-messages"
hx-swap="innerHTML"
onclick="this.setAttribute('hx-vals', JSON.stringify({agent_name: getActiveAgent()}))"
>+ New Chat</button>
<h3>History</h3>
<div id="sidebar-conversations"
hx-get="/chat/conversations?agent_name={{ default_agent }}"
hx-trigger="load"
hx-swap="innerHTML">
</div>
</aside>
<!-- Mobile sidebar toggle + overlay -->
<button class="sidebar-open-btn" onclick="toggleSidebar()" aria-label="Open sidebar">&#9776;</button>
<div id="sidebar-overlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
<!-- Chat area -->
<div class="chat-main">
<!-- Status bar -->
<div class="status-bar">
<span class="status-item">Model: <strong>{{ chat_model }}</strong></span>
<span class="status-item">Exec: <strong class="{% if exec_available %}text-ok{% else %}text-err{% endif %}">{{ "OK" if exec_available else "N/A" }}</strong></span>
<span class="status-item">ClickUp: <strong class="{% if clickup_enabled %}text-ok{% else %}text-err{% endif %}">{{ "ON" if clickup_enabled else "OFF" }}</strong></span>
</div>
<!-- Notification banner (populated by SSE) -->
<div id="notification-banner" class="notification-banner" style="display:none;"></div>
<!-- Messages -->
<div class="chat-messages" id="chat-messages">
<!-- Messages loaded here -->
</div>
<!-- Input area -->
<form id="chat-form" class="chat-input-area"
hx-post="/chat/send"
hx-target="#chat-messages"
hx-swap="beforeend"
hx-encoding="multipart/form-data"
hx-on::after-request="afterSend(event)">
<input type="hidden" name="agent_name" id="input-agent-name" value="{{ default_agent }}">
<input type="hidden" name="conv_id" id="input-conv-id" value="">
<div class="input-row">
<label class="file-upload-btn" title="Attach files">
&#x1f4ce;
<input type="file" name="files" multiple style="display:none;" onchange="showFileNames(this)">
</label>
<textarea name="text" id="chat-input" rows="1" placeholder="Type a message..."
onkeydown="handleKeydown(event)" oninput="autoResize(this)"></textarea>
<button type="submit" class="send-btn" title="Send">&#x27A4;</button>
</div>
<div id="file-preview" class="file-preview" style="display:none;"></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Initialize session state
const SESSION_KEY = 'cheddahbot_session';
let session = JSON.parse(localStorage.getItem(SESSION_KEY) || '{}');
if (!session.agent_name) session.agent_name = '{{ default_agent }}';
// Restore session on load
document.addEventListener('DOMContentLoaded', function() {
if (session.agent_name) {
setActiveAgent(session.agent_name);
}
if (session.conv_id) {
loadConversation(session.conv_id);
}
// Load conversations for sidebar
refreshSidebar();
});
function saveSession() {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
</script>
{% endblock %}

View File

@ -0,0 +1,174 @@
{% extends "base.html" %}
{% block title %}Dashboard - CheddahBot{% endblock %}
{% block nav_dash_active %}active{% endblock %}
{% block content %}
<div class="dashboard-layout">
<!-- Ops Panel -->
<section class="panel" id="ops-panel">
<h2 class="panel-title">Operations</h2>
<!-- Active Executions -->
<div class="panel-section">
<h3>Active Executions</h3>
<div id="active-executions" class="exec-list">
<span class="text-muted">Loading...</span>
</div>
</div>
<!-- Loop Health -->
<div class="panel-section">
<h3>Loop Health</h3>
<div id="loop-health" class="loop-grid">
<span class="text-muted">Loading...</span>
</div>
</div>
<!-- Actions -->
<div class="panel-section">
<h3>Actions</h3>
<div class="action-buttons">
<button class="btn btn-sm"
hx-post="/api/system/loops/force"
hx-swap="none"
hx-on::after-request="showFlash('Force pulse sent')">
Force Pulse
</button>
<button class="btn btn-sm"
hx-post="/api/system/briefing/force"
hx-swap="none"
hx-on::after-request="showFlash('Briefing triggered')">
Force Briefing
</button>
<button class="btn btn-sm"
hx-post="/api/cache/clear"
hx-swap="none"
hx-on::after-request="showFlash('Cache cleared')">
Clear Cache
</button>
</div>
</div>
<!-- Notification Feed -->
<div class="panel-section">
<h3>Notifications</h3>
<div id="notification-feed" class="notif-feed">
<span class="text-muted">Waiting for notifications...</span>
</div>
</div>
</section>
<!-- Pipeline Panel -->
<section class="panel" id="pipeline-panel">
<h2 class="panel-title">Pipeline</h2>
<div id="pipeline-content"
hx-get="/dashboard/pipeline"
hx-trigger="load, every 120s"
hx-swap="innerHTML">
<span class="text-muted">Loading pipeline data...</span>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
// Connect to SSE for live loop updates
const loopSource = new EventSource('/sse/loops');
loopSource.addEventListener('loops', function(e) {
const data = JSON.parse(e.data);
renderLoopHealth(data.loops);
renderActiveExecutions(data.executions);
});
// Connect to SSE for notifications
const notifSource = new EventSource('/sse/notifications');
notifSource.addEventListener('notification', function(e) {
const notif = JSON.parse(e.data);
addNotification(notif.message, notif.category);
});
function renderLoopHealth(loops) {
const container = document.getElementById('loop-health');
if (!loops || Object.keys(loops).length === 0) {
container.innerHTML = '<span class="text-muted">No loop data</span>';
return;
}
let html = '';
const now = new Date();
for (const [name, ts] of Object.entries(loops)) {
let statusClass = 'badge-muted';
let agoText = 'never';
if (ts) {
const dt = new Date(ts);
const secs = Math.floor((now - dt) / 1000);
if (secs < 120) {
statusClass = 'badge-ok';
agoText = secs + 's ago';
} else if (secs < 600) {
statusClass = 'badge-warn';
agoText = Math.floor(secs / 60) + 'm ago';
} else {
statusClass = 'badge-err';
agoText = Math.floor(secs / 60) + 'm ago';
}
}
html += '<div class="loop-badge ' + statusClass + '">' +
'<span class="loop-name">' + name + '</span>' +
'<span class="loop-ago">' + agoText + '</span>' +
'</div>';
}
container.innerHTML = html;
}
function renderActiveExecutions(execs) {
const container = document.getElementById('active-executions');
if (!execs || Object.keys(execs).length === 0) {
container.innerHTML = '<span class="text-muted">No active executions</span>';
return;
}
let html = '';
const now = new Date();
for (const [id, info] of Object.entries(execs)) {
const started = new Date(info.started_at);
const durSecs = Math.floor((now - started) / 1000);
let dur = durSecs + 's';
if (durSecs >= 60) dur = Math.floor(durSecs / 60) + 'm ' + (durSecs % 60) + 's';
html += '<div class="exec-item">' +
'<span class="exec-name">' + info.name + '</span>' +
'<span class="exec-tool">' + info.tool + '</span>' +
'<span class="exec-dur">' + dur + '</span>' +
'</div>';
}
container.innerHTML = html;
}
let notifCount = 0;
function addNotification(message, category) {
const container = document.getElementById('notification-feed');
if (notifCount === 0) container.innerHTML = '';
notifCount++;
const div = document.createElement('div');
div.className = 'notif-item notif-' + (category || 'info');
div.innerHTML = '<span class="notif-cat">' + (category || 'info') + '</span> ' + message;
container.insertBefore(div, container.firstChild);
// Keep max 30
while (container.children.length > 30) {
container.removeChild(container.lastChild);
}
}
function showFlash(msg) {
const el = document.createElement('div');
el.className = 'flash-msg';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
</script>
{% endblock %}

View File

@ -0,0 +1,6 @@
<div class="message {{ role }}">
<div class="message-avatar">{% if role == 'user' %}You{% else %}CB{% endif %}</div>
<div class="message-body">
<div class="message-content">{{ content }}</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
{% if conversations %}
{% for conv in conversations %}
<button class="conv-btn"
onclick="loadConversation('{{ conv.id }}')"
title="{{ conv.title or 'New Chat' }}">
{{ conv.title or 'New Chat' }}
</button>
{% endfor %}
{% else %}
<p class="text-muted">No conversations yet</p>
{% endif %}

View File

@ -0,0 +1,6 @@
{% for name, info in loops.items() %}
<div class="loop-badge {{ info.class }}">
<span class="loop-name">{{ name }}</span>
<span class="loop-ago">{{ info.ago }}</span>
</div>
{% endfor %}

View File

@ -0,0 +1,6 @@
{% for notif in notifications %}
<div class="notif-item notif-{{ notif.category or 'info' }}">
<span class="notif-cat">{{ notif.category }}</span>
{{ notif.message }}
</div>
{% endfor %}

View File

@ -0,0 +1,27 @@
{% if tasks %}
<table class="task-table">
<thead>
<tr>
<th>Task</th>
<th>Customer</th>
<th>Status</th>
<th>Due</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
{% if task.url %}<a href="{{ task.url }}" target="_blank" rel="noopener">{{ task.name }}</a>
{% else %}{{ task.name }}{% endif %}
</td>
<td>{{ task.custom_fields.get('Client', 'N/A') if task.custom_fields else 'N/A' }}</td>
<td><span class="status-badge status-{{ task.status|replace(' ', '-') }}">{{ task.status }}</span></td>
<td>{{ task.due_display or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No tasks</p>
{% endif %}

View File

@ -0,0 +1,57 @@
"""HTMX + FastAPI web frontend for CheddahBot."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles
if TYPE_CHECKING:
from ..agent_registry import AgentRegistry
from ..config import Config
from ..db import Database
from ..llm import LLMAdapter
from ..notifications import NotificationBus
from ..scheduler import Scheduler
log = logging.getLogger(__name__)
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
def mount_web_app(
app: FastAPI,
registry: AgentRegistry,
config: Config,
llm: LLMAdapter,
notification_bus: NotificationBus | None = None,
scheduler: Scheduler | None = None,
db: Database | None = None,
):
"""Mount all web routes and static files onto the FastAPI app."""
# Wire dependencies into route modules
from . import routes_chat, routes_pages, routes_sse
from .routes_chat import router as chat_router
from .routes_pages import router as pages_router
from .routes_sse import router as sse_router
routes_pages.setup(registry, config, llm, templates, db=db, scheduler=scheduler)
routes_chat.setup(registry, config, llm, db, templates)
routes_sse.setup(notification_bus, scheduler, db)
app.include_router(chat_router)
app.include_router(sse_router)
# Pages router last (it has catch-all GET /)
app.include_router(pages_router)
# Static files
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
log.info("Web UI mounted (templates: %s, static: %s)", _TEMPLATE_DIR, _STATIC_DIR)

View File

@ -0,0 +1,270 @@
"""Chat routes: send messages, stream responses, manage conversations."""
from __future__ import annotations
import asyncio
import logging
import tempfile
import time
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, Form, Request, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse
if TYPE_CHECKING:
from ..agent_registry import AgentRegistry
from ..config import Config
from ..db import Database
from ..llm import LLMAdapter
log = logging.getLogger(__name__)
router = APIRouter(prefix="/chat")
_registry: AgentRegistry | None = None
_config: Config | None = None
_llm: LLMAdapter | None = None
_db: Database | None = None
_templates: Jinja2Templates | None = None
# Pending responses: conv_id -> {text, files, timestamp}
_pending: dict[str, dict] = {}
def setup(registry, config, llm, db, templates):
global _registry, _config, _llm, _db, _templates
_registry = registry
_config = config
_llm = llm
_db = db
_templates = templates
def _get_agent(name: str):
if _registry:
return _registry.get(name) or _registry.default
return None
def _cleanup_pending():
"""Remove pending entries older than 60s."""
now = time.time()
expired = [k for k, v in _pending.items() if now - v["timestamp"] > 60]
for k in expired:
del _pending[k]
@router.post("/send")
async def send_message(
request: Request,
text: str = Form(""),
agent_name: str = Form("default"),
conv_id: str = Form(""),
files: list[UploadFile] | None = None,
):
"""Accept user message, return user bubble HTML + trigger SSE stream."""
_cleanup_pending()
agent = _get_agent(agent_name)
if not agent:
return HTMLResponse("<div class='error'>Agent not found</div>", status_code=400)
# Handle file uploads
saved_files = []
for f in (files or []):
if f.filename and f.size and f.size > 0:
tmp = Path(tempfile.mkdtemp()) / f.filename
content = await f.read()
tmp.write_bytes(content)
saved_files.append(str(tmp))
if not text.strip() and not saved_files:
return HTMLResponse("")
# Ensure conversation exists
if not conv_id:
agent.new_conversation()
conv_id = agent.ensure_conversation()
else:
agent.conv_id = conv_id
# Build display text
display_text = text
if saved_files:
file_names = [Path(f).name for f in saved_files]
display_text += f"\n[Attached: {', '.join(file_names)}]"
# Stash for SSE stream
_pending[conv_id] = {
"text": text,
"files": saved_files,
"timestamp": time.time(),
"agent_name": agent_name,
}
# Render user bubble + SSE trigger div
user_html = _templates.get_template("partials/chat_message.html").render(
role="user", content=display_text
)
# The SSE trigger div connects to the stream endpoint
sse_div = (
f'<div id="sse-trigger" '
f'hx-ext="sse" '
f'sse-connect="/chat/stream/{conv_id}" '
f'sse-swap="chunk" '
f'hx-target="#assistant-response" '
f'hx-swap="beforeend">'
f'</div>'
f'<div id="assistant-bubble" class="message assistant">'
f'<div class="message-avatar">CB</div>'
f'<div class="message-body">'
f'<div id="assistant-response" class="message-content"></div>'
f'</div></div>'
)
headers = {
"HX-Trigger-After-Swap": "scrollChat",
"HX-Push-Url": f"/?conv={conv_id}",
}
return HTMLResponse(user_html + sse_div, headers=headers)
@router.get("/stream/{conv_id}")
async def stream_response(conv_id: str):
"""SSE endpoint: stream assistant response chunks."""
pending = _pending.pop(conv_id, None)
if not pending:
async def empty():
yield {"event": "done", "data": ""}
return EventSourceResponse(empty())
agent = _get_agent(pending["agent_name"])
if not agent:
async def error():
yield {"event": "chunk", "data": "Agent not found"}
yield {"event": "done", "data": ""}
return EventSourceResponse(error())
agent.conv_id = conv_id
async def generate():
loop = asyncio.get_event_loop()
queue: asyncio.Queue = asyncio.Queue()
def run_agent():
try:
for chunk in agent.respond(pending["text"], files=pending.get("files")):
loop.call_soon_threadsafe(queue.put_nowait, ("chunk", chunk))
except Exception as e:
log.error("Stream error: %s", e, exc_info=True)
loop.call_soon_threadsafe(
queue.put_nowait, ("chunk", f"\n\nError: {e}")
)
finally:
loop.call_soon_threadsafe(queue.put_nowait, ("done", ""))
# Run agent.respond() in a thread
import threading
t = threading.Thread(target=run_agent, daemon=True)
t.start()
while True:
event, data = await queue.get()
if event == "done":
yield {"event": "done", "data": conv_id}
break
yield {"event": "chunk", "data": data}
return EventSourceResponse(generate())
@router.get("/conversations")
async def list_conversations(agent_name: str = "default"):
"""Return sidebar conversation list as HTML partial."""
agent = _get_agent(agent_name)
if not agent:
return HTMLResponse("")
convs = agent.db.list_conversations(limit=50, agent_name=agent_name)
html = _templates.get_template("partials/chat_sidebar.html").render(
conversations=convs
)
return HTMLResponse(html)
@router.post("/new")
async def new_conversation(agent_name: str = Form("default")):
"""Create a new conversation, return empty chat + updated sidebar."""
agent = _get_agent(agent_name)
if not agent:
return HTMLResponse("")
agent.new_conversation()
conv_id = agent.ensure_conversation()
convs = agent.db.list_conversations(limit=50, agent_name=agent_name)
sidebar_html = _templates.get_template("partials/chat_sidebar.html").render(
conversations=convs
)
# Return empty chat area + sidebar update via OOB swap
html = (
f'<div id="chat-messages"></div>'
f'<div id="sidebar-conversations" hx-swap-oob="innerHTML">'
f'{sidebar_html}</div>'
)
headers = {"HX-Push-Url": f"/?conv={conv_id}"}
return HTMLResponse(html, headers=headers)
@router.get("/load/{conv_id}")
async def load_conversation(conv_id: str, agent_name: str = "default"):
"""Load conversation history as HTML."""
agent = _get_agent(agent_name)
if not agent:
return HTMLResponse("")
messages = agent.load_conversation(conv_id)
parts = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
if role in ("user", "assistant") and content:
parts.append(
_templates.get_template("partials/chat_message.html").render(
role=role, content=content
)
)
headers = {"HX-Push-Url": f"/?conv={conv_id}"}
return HTMLResponse("\n".join(parts), headers=headers)
@router.post("/agent/{name}")
async def switch_agent(name: str):
"""Switch active agent. Returns updated sidebar via OOB."""
agent = _get_agent(name)
if not agent:
return HTMLResponse("<div class='error'>Agent not found</div>", status_code=400)
agent.new_conversation()
conv_id = agent.ensure_conversation()
convs = agent.db.list_conversations(limit=50, agent_name=name)
sidebar_html = _templates.get_template("partials/chat_sidebar.html").render(
conversations=convs
)
html = (
f'<div id="chat-messages"></div>'
f'<div id="sidebar-conversations" hx-swap-oob="innerHTML">'
f'{sidebar_html}</div>'
)
headers = {"HX-Push-Url": f"/?conv={conv_id}"}
return HTMLResponse(html, headers=headers)

View File

@ -0,0 +1,172 @@
"""Page routes: GET / (chat), GET /dashboard, dashboard partials."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
if TYPE_CHECKING:
from ..agent_registry import AgentRegistry
from ..config import Config
from ..db import Database
from ..llm import LLMAdapter
from ..scheduler import Scheduler
log = logging.getLogger(__name__)
router = APIRouter()
_registry: AgentRegistry | None = None
_config: Config | None = None
_llm: LLMAdapter | None = None
_db: Database | None = None
_scheduler: Scheduler | None = None
_templates: Jinja2Templates | None = None
def setup(registry, config, llm, templates, db=None, scheduler=None):
global _registry, _config, _llm, _templates, _db, _scheduler
_registry = registry
_config = config
_llm = llm
_templates = templates
_db = db
_scheduler = scheduler
@router.get("/")
async def chat_page(request: Request):
agent_names = _registry.list_agents() if _registry else []
agents = []
for name in agent_names:
agent = _registry.get(name)
display = agent.agent_config.display_name if agent else name
agents.append({"name": name, "display_name": display})
default_agent = _registry.default_name if _registry else "default"
chat_model = _config.chat_model if _config else "unknown"
exec_available = _llm.is_execution_brain_available() if _llm else False
clickup_enabled = _config.clickup.enabled if _config else False
return _templates.TemplateResponse("chat.html", {
"request": request,
"agents": agents,
"default_agent": default_agent,
"chat_model": chat_model,
"exec_available": exec_available,
"clickup_enabled": clickup_enabled,
})
@router.get("/dashboard")
async def dashboard_page(request: Request):
return _templates.TemplateResponse("dashboard.html", {
"request": request,
})
@router.get("/dashboard/pipeline")
async def dashboard_pipeline():
"""Return pipeline panel HTML partial with task data."""
if not _config or not _config.clickup.enabled:
return HTMLResponse('<p class="text-muted">ClickUp not configured</p>')
try:
from ..api import get_tasks
data = await get_tasks()
all_tasks = data.get("tasks", [])
except Exception as e:
log.error("Pipeline data fetch failed: %s", e)
return HTMLResponse(f'<p class="text-err">Error: {e}</p>')
# Group by work category, then by status
pipeline_statuses = [
"to do", "automation underway", "outline review", "internal review", "error",
]
categories = {} # category -> {status -> [tasks]}
for t in all_tasks:
cat = t.get("task_type") or "Other"
status = t.get("status", "unknown")
# Only show tasks in pipeline-relevant statuses
if status not in pipeline_statuses:
continue
if cat not in categories:
categories[cat] = {}
categories[cat].setdefault(status, []).append(t)
# Build HTML
html_parts = []
# Status summary counts
total_counts = {}
for cat_data in categories.values():
for status, tasks in cat_data.items():
total_counts[status] = total_counts.get(status, 0) + len(tasks)
if total_counts:
html_parts.append('<div class="pipeline-stats">')
for status in pipeline_statuses:
count = total_counts.get(status, 0)
html_parts.append(
f'<div class="pipeline-stat">'
f'<div class="stat-count">{count}</div>'
f'<div class="stat-label">{status}</div>'
f'</div>'
)
html_parts.append('</div>')
# Per-category tables
for cat_name in sorted(categories.keys()):
cat_data = categories[cat_name]
all_cat_tasks = []
for status in pipeline_statuses:
all_cat_tasks.extend(cat_data.get(status, []))
if not all_cat_tasks:
continue
html_parts.append(f'<div class="pipeline-group"><h4>{cat_name} ({len(all_cat_tasks)})</h4>')
html_parts.append('<table class="task-table"><thead><tr>'
'<th>Task</th><th>Customer</th><th>Status</th><th>Due</th>'
'</tr></thead><tbody>')
for task in all_cat_tasks:
name = task.get("name", "")
url = task.get("url", "")
customer = (task.get("custom_fields") or {}).get("Client", "N/A")
status = task.get("status", "")
status_class = "status-" + status.replace(" ", "-")
# Format due date
due_display = "-"
due_raw = task.get("due_date")
if due_raw:
try:
due_dt = datetime.fromtimestamp(int(due_raw) / 1000, tz=UTC)
due_display = due_dt.strftime("%b %d")
except (ValueError, TypeError, OSError):
pass
name_cell = (
f'<a href="{url}" target="_blank">{name}</a>' if url else name
)
html_parts.append(
f'<tr><td>{name_cell}</td><td>{customer}</td>'
f'<td><span class="status-badge {status_class}">{status}</span></td>'
f'<td>{due_display}</td></tr>'
)
html_parts.append('</tbody></table></div>')
if not html_parts:
return HTMLResponse('<p class="text-muted">No active pipeline tasks</p>')
return HTMLResponse('\n'.join(html_parts))

View File

@ -0,0 +1,94 @@
"""SSE routes for live dashboard updates."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from fastapi import APIRouter
from sse_starlette.sse import EventSourceResponse
if TYPE_CHECKING:
from ..db import Database
from ..notifications import NotificationBus
from ..scheduler import Scheduler
log = logging.getLogger(__name__)
router = APIRouter(prefix="/sse")
_notification_bus: NotificationBus | None = None
_scheduler: Scheduler | None = None
_db: Database | None = None
def setup(notification_bus, scheduler, db):
global _notification_bus, _scheduler, _db
_notification_bus = notification_bus
_scheduler = scheduler
_db = db
@router.get("/notifications")
async def sse_notifications():
"""Stream new notifications as they arrive."""
listener_id = f"sse-notif-{id(asyncio.current_task())}"
# Subscribe to notification bus
queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_event_loop()
if _notification_bus:
def on_notify(msg, cat):
loop.call_soon_threadsafe(
queue.put_nowait, {"message": msg, "category": cat}
)
_notification_bus.subscribe(listener_id, on_notify)
async def generate():
try:
while True:
try:
notif = await asyncio.wait_for(queue.get(), timeout=30)
yield {
"event": "notification",
"data": json.dumps(notif),
}
except TimeoutError:
yield {"event": "heartbeat", "data": ""}
finally:
if _notification_bus:
_notification_bus.unsubscribe(listener_id)
return EventSourceResponse(generate())
@router.get("/loops")
async def sse_loops():
"""Push loop timestamps + active executions every 15s."""
async def generate():
while True:
data = {"loops": {}, "executions": {}}
if _scheduler:
ts = _scheduler.get_loop_timestamps()
data["loops"] = ts
# Serialize active executions (datetime -> str)
raw_exec = _scheduler.get_active_executions()
execs = {}
for tid, info in raw_exec.items():
execs[tid] = {
"name": info.get("name", ""),
"tool": info.get("tool", ""),
"started_at": info["started_at"].isoformat()
if isinstance(info.get("started_at"), datetime)
else str(info.get("started_at", "")),
"thread": info.get("thread", ""),
}
data["executions"] = execs
yield {"event": "loops", "data": json.dumps(data)}
await asyncio.sleep(15)
return EventSourceResponse(generate())

View File

@ -16,6 +16,9 @@ dependencies = [
"edge-tts>=6.1",
"python-docx>=1.2.0",
"openpyxl>=3.1.5",
"jinja2>=3.1.6",
"python-multipart>=0.0.22",
"sse-starlette>=3.3.3",
]
[build-system]

25
uv.lock
View File

@ -325,13 +325,16 @@ dependencies = [
{ name = "edge-tts" },
{ name = "gradio" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "numpy" },
{ name = "openai" },
{ name = "openpyxl" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "sentence-transformers" },
{ name = "sse-starlette" },
]
[package.dev-dependencies]
@ -357,13 +360,16 @@ requires-dist = [
{ name = "edge-tts", specifier = ">=6.1" },
{ name = "gradio", specifier = ">=5.0" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "openai", specifier = ">=1.30" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "sentence-transformers", specifier = ">=3.0" },
{ name = "sse-starlette", specifier = ">=3.3.3" },
]
[package.metadata.requires-dev]
@ -2556,6 +2562,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "sse-starlette"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
@ -2722,6 +2741,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" },
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
{ url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" },
{ url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" },
{ url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" },