diff --git a/cheddahbot/__main__.py b/cheddahbot/__main__.py index 8ff104c..2c7cd2a 100644 --- a/cheddahbot/__main__.py +++ b/cheddahbot/__main__.py @@ -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) diff --git a/cheddahbot/static/app.css b/cheddahbot/static/app.css new file mode 100644 index 0000000..9b8ba46 --- /dev/null +++ b/cheddahbot/static/app.css @@ -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); } diff --git a/cheddahbot/static/app.js b/cheddahbot/static/app.js new file mode 100644 index 0000000..b07ced2 --- /dev/null +++ b/cheddahbot/static/app.js @@ -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 += '' + f.name + ''; + } + 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 = '
No conversations yet
+{% endif %} diff --git a/cheddahbot/templates/partials/loop_status.html b/cheddahbot/templates/partials/loop_status.html new file mode 100644 index 0000000..1914730 --- /dev/null +++ b/cheddahbot/templates/partials/loop_status.html @@ -0,0 +1,6 @@ +{% for name, info in loops.items() %} +| Task | +Customer | +Status | +Due | +
|---|---|---|---|
| + {% if task.url %}{{ task.name }} + {% else %}{{ task.name }}{% endif %} + | +{{ task.custom_fields.get('Client', 'N/A') if task.custom_fields else 'N/A' }} | +{{ task.status }} | +{{ task.due_display or '-' }} | +
No tasks
+{% endif %} diff --git a/cheddahbot/web/__init__.py b/cheddahbot/web/__init__.py new file mode 100644 index 0000000..78e286c --- /dev/null +++ b/cheddahbot/web/__init__.py @@ -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) diff --git a/cheddahbot/web/routes_chat.py b/cheddahbot/web/routes_chat.py new file mode 100644 index 0000000..371dd8f --- /dev/null +++ b/cheddahbot/web/routes_chat.py @@ -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("ClickUp not configured
') + + 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'Error: {e}
') + + # 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('| Task | Customer | Status | Due | ' + '
|---|---|---|---|
| {name_cell} | {customer} | ' + f'{status} | ' + f'{due_display} |
No active pipeline tasks
') + + return HTMLResponse('\n'.join(html_parts)) diff --git a/cheddahbot/web/routes_sse.py b/cheddahbot/web/routes_sse.py new file mode 100644 index 0000000..9936149 --- /dev/null +++ b/cheddahbot/web/routes_sse.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index 03597d1..ad3e210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/uv.lock b/uv.lock index 6aa1314..d485da0 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },