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
parent
6e7e2b2320
commit
74d20a406a
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">✕</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">☰</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">
|
||||
📎
|
||||
<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">➤</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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
25
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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue