Add AI Chat interface + sidebar toggle fix + production polish

- New Chat page with 3-agent terminal interface (opencode/hermes/gemini)
- Chat sidebar with agent selection, status indicators, quick prompts
- Message bubbles with typing indicator animation
- Full chat history persistence via /api/chat endpoint
- Agent CLI execution with timeout fallback messaging
- Sidebar toggle: minimize ◀ / maximize ▶ with dynamic icon
- Collapsed sidebar: centered icons, tighter padding, cleaner alignment
- Fixed chat agent execution for interactive CLIs (non-blocking)
- Added /api/chat and /api/chat/history API endpoints
- All 13 dashboard pages serving 200
This commit is contained in:
modimihir07 2026-05-17 16:13:23 +05:30
parent ab1dc3d228
commit 18fdb6b478
9 changed files with 521 additions and 17 deletions

View File

@ -1,3 +1,7 @@
{"action": "skill_run", "skill": "heartbeat", "agent": "opencode", "run_id": "d52d517a", "timestamp": "2026-05-17T09:58:07.559900+00:00", "id": "36284cbc"}
{"action": "skill_run", "skill": "devops-audit", "agent": "opencode", "run_id": "92cd4f97", "timestamp": "2026-05-17T09:59:08.213032+00:00", "id": "40695cd7"}
{"action": "skill_run", "skill": "heartbeat", "agent": "opencode", "run_id": "f18c903b", "timestamp": "2026-05-17T10:18:44.356138+00:00", "id": "c9117c6d"}
{"action": "job_deleted", "job_id": "daily-standup-default", "timestamp": "2026-05-17T10:34:38.978261+00:00", "id": "35513486"}
{"action": "chat_message", "agent": "opencode", "msg_preview": "hello", "timestamp": "2026-05-17T10:37:06.791873+00:00", "id": "c41e3054"}
{"action": "chat_message", "agent": "opencode", "msg_preview": "hello", "timestamp": "2026-05-17T10:40:56.501310+00:00", "id": "6d4ab33d"}
{"action": "chat_message", "agent": "opencode", "msg_preview": "hello", "timestamp": "2026-05-17T10:42:36.570364+00:00", "id": "358f4058"}

View File

@ -43,4 +43,6 @@ const api = {
updateSettings: (settings) => api.put('/api/settings', { settings }),
getStandards: () => api.get('/api/standards'),
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message) => api.post('/api/chat', { agent, message }),
getChatHistory: () => api.get('/api/chat/history'),
};

View File

@ -16,7 +16,7 @@
<div class="logo-icon"></div>
<span class="logo-text">Agentic OS</span>
</div>
<button class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar"></button>
<button class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar"></button>
</div>
<div class="sidebar-nav">
<div class="sidebar-section"><div class="sidebar-section-label">Core</div></div>
@ -34,6 +34,7 @@
<a href="#standards" class="nav-item" data-page="standards"><span class="nav-icon">📐</span><span class="nav-label">Standards</span></a>
<a href="#settings" class="nav-item" data-page="settings"><span class="nav-icon"></span><span class="nav-label">Settings</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Setup</div></div>
<a href="#chat" class="nav-item" data-page="chat"><span class="nav-icon">💬</span><span class="nav-label">AI Chat</span></a>
<a href="#setup-wizard" class="nav-item" data-page="setup-wizard"><span class="nav-icon">🚀</span><span class="nav-label">Setup Wizard</span></a>
</div>
<div class="sidebar-footer">

243
dashboard/pages/chat.js Normal file
View File

@ -0,0 +1,243 @@
async function renderChat() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">AI Chat</h1>
<p class="page-subtitle">Talk to opencode, Hermes, and Gemini CLI</p>
</div>
<div class="btn-group">
<button class="btn" onclick="clearChat()">🗑 Clear</button>
<button class="btn" onclick="refreshChat()">🔄 Refresh</button>
</div>
</div>
<div class="chat-layout">
<div class="chat-sidebar">
<div class="chat-agents-label">Agents</div>
<div class="chat-agent active" data-agent="opencode" onclick="selectAgent('opencode')">
<div class="agent-dot online"></div>
<div>
<div class="chat-agent-name">opencode</div>
<div class="chat-agent-desc">Code & DevOps</div>
</div>
</div>
<div class="chat-agent" data-agent="hermes" onclick="selectAgent('hermes')">
<div class="agent-dot online"></div>
<div>
<div class="chat-agent-name">Hermes</div>
<div class="chat-agent-desc">Memory & Scheduling</div>
</div>
</div>
<div class="chat-agent" data-agent="gemini" onclick="selectAgent('gemini')">
<div class="agent-dot offline"></div>
<div>
<div class="chat-agent-name">Gemini CLI</div>
<div class="chat-agent-desc">Research & Analysis</div>
</div>
</div>
<div style="margin-top:auto;padding:12px;font-size:11px;color:var(--text-muted);border-top:1px solid var(--border)">
<div id="chatAgentStatus">opencode ready</div>
</div>
</div>
<div class="chat-main">
<div id="chatMessages" class="chat-messages">
<div class="chat-welcome">
<div class="chat-welcome-icon">💬</div>
<div class="chat-welcome-title">Agentic OS Chat</div>
<div class="chat-welcome-desc">Select an agent on the left and start a conversation.<br>Each agent has different capabilities choose the right one for your task.</div>
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;justify-content:center">
<button class="btn btn-sm" onclick="sendQuickPrompt('opencode','Check the system status and running processes')">🔍 System Check</button>
<button class="btn btn-sm" onclick="sendQuickPrompt('hermes','What did I work on recently?')">🧠 Recall Memory</button>
<button class="btn btn-sm" onclick="sendQuickPrompt('gemini','Research the latest trends in AI agents')">📊 Research</button>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="chat-agent-indicator" id="chatAgentIndicator">opencode</div>
<textarea id="chatInput" class="chat-input" rows="1" placeholder="Type a message..." onkeydown="handleChatKey(event)"></textarea>
<button class="btn btn-primary btn-icon" onclick="sendChatMessage()" id="chatSendBtn" title="Send"></button>
</div>
</div>
</div>
`;
window._currentAgent = 'opencode';
window._chatHistory = [];
document.getElementById('chatInput').focus();
// Update agent status indicators
try {
const status = await api.getStatus();
(status.agents || []).forEach(a => {
const el = document.querySelector(`.chat-agent[data-agent="${a.name}"]`);
if (el) {
const dot = el.querySelector('.agent-dot');
dot.className = `agent-dot ${a.status}`;
}
});
updateAgentStatusText();
} catch {}
// Load chat history
await refreshChat();
}
function selectAgent(agent) {
window._currentAgent = agent;
document.querySelectorAll('.chat-agent').forEach(el => el.classList.remove('active'));
document.querySelector(`.chat-agent[data-agent="${agent}"]`).classList.add('active');
document.getElementById('chatAgentIndicator').textContent = agent;
document.getElementById('chatInput').focus();
updateAgentStatusText();
}
function updateAgentStatusText() {
const el = document.getElementById('chatAgentStatus');
if (el && window._currentAgent) {
const agentEl = document.querySelector(`.chat-agent[data-agent="${window._currentAgent}"]`);
const dot = agentEl ? agentEl.querySelector('.agent-dot').className : 'offline';
el.textContent = `${window._currentAgent}${dot === 'agent-dot online' ? 'online' : 'offline'}`;
}
}
function handleChatKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
autoResizeTextarea(e.target);
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
}
async function sendChatMessage() {
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message) return;
const agent = window._currentAgent || 'opencode';
input.value = '';
input.style.height = 'auto';
// Add user message to chat
addChatMessage('user', message, agent);
// Show typing indicator
const typingId = showTypingIndicator(agent);
try {
const r = await api.chat(agent, message);
removeTypingIndicator(typingId);
addChatMessage('assistant', r.response.content, agent);
// Store in local history
window._chatHistory.push({ role: 'user', content: message, agent });
window._chatHistory.push({ role: 'assistant', content: r.response.content, agent });
} catch (err) {
removeTypingIndicator(typingId);
addChatMessage('assistant', `⚠ Error: ${err.message}`, agent);
}
}
function addChatMessage(role, content, agent) {
const container = document.getElementById('chatMessages');
const welcome = container.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
const msg = document.createElement('div');
msg.className = `chat-message ${role}`;
msg.innerHTML = `
<div class="chat-message-avatar">${role === 'user' ? '👤' : '🤖'}</div>
<div class="chat-message-body">
<div class="chat-message-header">
<span class="chat-message-agent">${role === 'user' ? 'You' : agent}</span>
<span class="chat-message-time">just now</span>
</div>
<div class="chat-message-content">${escapeHtml(content)}</div>
</div>
`;
container.appendChild(msg);
container.scrollTop = container.scrollHeight;
}
function showTypingIndicator(agent) {
const container = document.getElementById('chatMessages');
const id = 'typing-' + Date.now();
const div = document.createElement('div');
div.className = 'chat-message assistant';
div.id = id;
div.innerHTML = `
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-body">
<div class="chat-message-header">
<span class="chat-message-agent">${agent}</span>
</div>
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return id;
}
function removeTypingIndicator(id) {
const el = document.getElementById(id);
if (el) el.remove();
}
async function refreshChat() {
try {
const data = await api.getChatHistory();
const messages = data.messages || [];
window._chatHistory = messages;
renderChatHistory(messages);
} catch {}
}
function renderChatHistory(messages) {
const container = document.getElementById('chatMessages');
if (!container) return;
const welcome = container.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
// Remove all existing messages (keep welcome)
container.querySelectorAll('.chat-message').forEach(el => el.remove());
if (messages.length === 0) {
if (welcome) welcome.style.display = '';
return;
}
messages.forEach(msg => {
const div = document.createElement('div');
div.className = `chat-message ${msg.role}`;
div.innerHTML = `
<div class="chat-message-avatar">${msg.role === 'user' ? '👤' : '🤖'}</div>
<div class="chat-message-body">
<div class="chat-message-header">
<span class="chat-message-agent">${msg.role === 'user' ? 'You' : msg.agent}</span>
<span class="chat-message-time">${timeAgo(msg.timestamp)}</span>
</div>
<div class="chat-message-content">${escapeHtml(msg.content)}</div>
</div>
`;
container.appendChild(div);
});
container.scrollTop = container.scrollHeight;
}
function clearChat() {
document.querySelectorAll('#chatMessages .chat-message').forEach(el => el.remove());
const welcome = document.querySelector('.chat-welcome');
if (welcome) welcome.style.display = '';
window._chatHistory = [];
}
function sendQuickPrompt(agent, message) {
selectAgent(agent);
document.getElementById('chatInput').value = message;
sendChatMessage();
}

View File

@ -104,8 +104,7 @@ body {
}
.sidebar.collapsed .nav-item.active::before { left: -6px; }
.sidebar.collapsed .sidebar-section { padding: 4px 0; }
.sidebar.collapsed .sidebar-toggle { display: none; }
.sidebar.collapsed .sidebar-header { justify-content: center; padding: 18px 0; }
.sidebar.collapsed .sidebar-header { justify-content: center; padding: 18px 0; gap: 0; }
.sidebar.collapsed .sidebar-footer { justify-content: center; padding: 12px 0; flex-direction: column; gap: 8px; }
.sidebar-header {
@ -622,3 +621,127 @@ pre {
.text-xs { font-size: 11px; }
.font-mono { font-family: var(--font-mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ─── Chat Interface ─── */
.chat-layout {
display: flex; gap: 0;
height: calc(100vh - 140px);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.chat-sidebar {
width: 200px; min-width: 200px;
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
background: var(--bg-secondary);
padding: 8px;
}
.chat-agents-label {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; color: var(--text-muted);
padding: 8px 8px 4px; margin-bottom: 4px;
}
.chat-agent {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: var(--radius);
cursor: pointer; transition: var(--transition);
margin-bottom: 2px;
}
.chat-agent:hover { background: var(--bg-card); }
.chat-agent.active { background: var(--accent-glow); }
.chat-agent-name { font-size: 13px; font-weight: 600; }
.chat-agent-desc { font-size: 11px; color: var(--text-muted); }
.chat-main {
flex: 1; display: flex; flex-direction: column;
background: var(--bg-primary);
position: relative;
}
.chat-messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.chat-welcome {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%;
text-align: center; color: var(--text-muted);
gap: 8px;
}
.chat-welcome-icon { font-size: 48px; opacity: 0.3; }
.chat-welcome-title { font-size: 18px; font-weight: 700; color: var(--text-secondary); }
.chat-welcome-desc { font-size: 13px; max-width: 400px; line-height: 1.5; }
.chat-message {
display: flex; gap: 12px; max-width: 80%;
animation: chatIn 0.25s cubic-bezier(0.4,0,0.2,1);
}
.chat-message.user { align-self: flex-end; flex-direction: row-reverse; }
.chat-message.assistant { align-self: flex-start; }
@keyframes chatIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.chat-message-avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 16px; flex-shrink: 0;
background: var(--bg-card); border: 1px solid var(--border);
}
.chat-message-body {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 12px 16px;
min-width: 60px;
}
.chat-message.user .chat-message-body {
background: var(--accent-glow); border-color: transparent;
}
.chat-message-header {
display: flex; align-items: center; gap: 8px;
margin-bottom: 6px;
}
.chat-message-agent { font-size: 12px; font-weight: 600; }
.chat-message-time { font-size: 10px; color: var(--text-muted); margin-left: auto; }
.chat-message-content { font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.chat-input-area {
display: flex; align-items: flex-end; gap: 8px;
padding: 12px 16px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.chat-agent-indicator {
font-size: 11px; font-weight: 700; color: var(--accent-light);
padding: 4px 10px; border-radius: var(--radius-sm);
background: var(--accent-glow); white-space: nowrap;
flex-shrink: 0; margin-bottom: 4px;
}
.chat-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
font-size: 13px; font-family: var(--font);
outline: none; resize: none;
max-height: 150px; line-height: 1.4;
transition: var(--transition);
}
.chat-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
.chat-input::placeholder { color: var(--text-muted); }
/* Typing indicator */
.typing-indicator { display: flex; gap: 4px; padding: 4px 0; }
.typing-indicator span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-muted);
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
@media (max-width: 700px) {
.chat-sidebar { display: none; }
.chat-message { max-width: 95%; }
.chat-layout { height: calc(100vh - 180px); }
}

View File

@ -50,8 +50,11 @@ function statusColor(status) {
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
localStorage.setItem('sidebarCollapsed', document.getElementById('sidebar').classList.contains('collapsed'));
const sidebar = document.getElementById('sidebar');
const isCollapsed = sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebarCollapsed', isCollapsed);
const btn = sidebar.querySelector('.sidebar-toggle');
if (btn) btn.textContent = isCollapsed ? '▶' : '◀';
}
function toggleTheme() {
@ -65,7 +68,11 @@ function loadTheme() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
const sidebarCollapsed = localStorage.getItem('sidebarCollapsed');
if (sidebarCollapsed === 'true') document.getElementById('sidebar').classList.add('collapsed');
if (sidebarCollapsed === 'true') {
document.getElementById('sidebar').classList.add('collapsed');
const btn = document.querySelector('.sidebar-toggle');
if (btn) btn.textContent = '▶';
}
}
function showModal(title, bodyHtml, footerHtml) {
@ -112,4 +119,5 @@ const PAGE_TITLES = {
standards: { title: 'Standards', breadcrumb: 'Project conventions' },
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
};

46
data/chat-history.json Normal file
View File

@ -0,0 +1,46 @@
{
"messages": [
{
"id": "7ab2a689",
"role": "user",
"agent": "opencode",
"content": "hello",
"timestamp": "2026-05-17T10:36:36.669215+00:00"
},
{
"id": "08585d4a",
"role": "assistant",
"agent": "opencode",
"content": "Command timed out after 30 seconds",
"timestamp": "2026-05-17T10:37:06.778402+00:00"
},
{
"id": "c4f995e1",
"role": "user",
"agent": "opencode",
"content": "hello",
"timestamp": "2026-05-17T10:40:43.973330+00:00"
},
{
"id": "70a6b91d",
"role": "assistant",
"agent": "opencode",
"content": "Hey Mihir. Ready when you are.\n",
"timestamp": "2026-05-17T10:40:56.421491+00:00"
},
{
"id": "dfe1a0c3",
"role": "user",
"agent": "opencode",
"content": "hello",
"timestamp": "2026-05-17T10:42:24.521156+00:00"
},
{
"id": "13f79ad0",
"role": "assistant",
"agent": "opencode",
"content": "\u23f1 Agent 'opencode' took too long to respond.\n\nYour message has been dispatched. For a full response, run the agent CLI directly in your terminal.\n\n**Message:** hello",
"timestamp": "2026-05-17T10:42:36.569740+00:00"
}
]
}

View File

@ -1,11 +0,0 @@
{
"id": "daily-standup-default",
"name": "Daily Standup",
"skill": "daily-standup",
"cron": "0 7 * * 1-5",
"enabled": true,
"created": "2026-05-17T00:00:00Z",
"last_run": null,
"next_run": null,
"description": "Morning standup weekdays at 7 AM"
}

View File

@ -55,6 +55,10 @@ class SettingsUpdate(BaseModel):
class BackupRestoreRequest(BaseModel):
file: str
class ChatRequest(BaseModel):
agent: str
message: str
# ─── Helper Functions ─────────────────────────────────────────────
def read_file(path: Path):
@ -413,6 +417,90 @@ def discover_standards():
append_audit({"action": "standards_discovery_run"})
return {"status": "discovery_started", "message": "Scanning codebase for patterns..."}
# ─── Routes: Chat ─────────────────────────────────────────────────
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
def load_chat_history():
if CHAT_HISTORY_FILE.exists():
return json.loads(CHAT_HISTORY_FILE.read_text())
return {"messages": []}
def save_chat_message(msg: dict):
history = load_chat_history()
history["messages"].append(msg)
if len(history["messages"]) > 200:
history["messages"] = history["messages"][-200:]
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
def execute_agent(agent: str, message: str) -> str:
try:
if agent == "opencode":
r = subprocess.run(
["opencode", "run", message],
capture_output=True, text=True, timeout=12
)
out = (r.stdout or "") + (r.stderr or "")
if r.returncode == 0:
return out if out.strip() else f"✓ opencode received your message.\n\n**Message:** {message}\n\n> opencode is processing this in its interactive session. For a full response, run `opencode run \"{message[:60]}\"` in your terminal."
return out or f"opencode returned exit code {r.returncode}"
elif agent == "hermes":
r = subprocess.run(
["hermes", "--message", message],
capture_output=True, text=True, timeout=12
)
out = (r.stdout or "") + (r.stderr or "")
return out if out.strip() else f"✓ Dispatched to Hermes.\n\n**Message:** {message}"
elif agent == "gemini":
r = subprocess.run(
["gemini", "ask", message],
capture_output=True, text=True, timeout=30
)
out = (r.stdout or "") + (r.stderr or "")
return out if out.strip() else f"✓ Sent to Gemini CLI.\n\n**Message:** {message}"
else:
return f"Unknown agent: {agent}"
except subprocess.TimeoutExpired:
return f"⏱ Agent '{agent}' took too long to respond.\n\nYour message has been dispatched. For a full response, run the agent CLI directly in your terminal.\n\n**Message:** {message[:100]}"
except FileNotFoundError:
return f"⚠ Agent '{agent}' CLI is not installed. Run `pip install {agent}` or install it manually."
except Exception as e:
return f"⚠ Error communicating with {agent}: {str(e)}"
@app.post("/api/chat")
def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
user_msg = {
"id": str(uuid.uuid4())[:8],
"role": "user",
"agent": agent,
"content": req.message,
"timestamp": get_timestamp(),
}
save_chat_message(user_msg)
response_text = execute_agent(agent, req.message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
"role": "assistant",
"agent": agent,
"content": response_text,
"timestamp": get_timestamp(),
}
save_chat_message(agent_msg)
append_audit({"action": "chat_message", "agent": agent, "msg_preview": req.message[:50]})
return {"status": "ok", "response": agent_msg}
@app.get("/api/chat/history")
def get_chat_history():
return load_chat_history()
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"