v0.2.0: 7 new features + UI modernization
New features: - Kanban Board: Visual task management with drag-and-drop, filter, priority, block/unblock - Goals: Project targets with progress tracking, auto-sync to brain/active-projects.md - Journal: Daily entries stored as brain/journal/YYYY-MM-DD.md with search - Agent Health: Real-time monitoring of 3 agents (opencode/hermes/gemini) - Smart Router: Keyword-based task routing with confidence scoring - Learning Analytics: Skill evaluation scores and performance trends - Session Replay: Browse and replay past opencode sessions Technical changes: - 30 new API endpoints across all 7 features - api.patch() method added for PATCH support - UI modernization: glass morphism cards, gradients, glow effects, skeleton loaders, empty states - New CSS section (v0.2.0) with kanban board, goal cards, journal entries, agent health cards, smart router suggestions, chart cards, session messages - 7 new sidebar nav items under Workflow and Monitoring sections - All changes are additive (zero breaking changes to existing 13 pages / 28 endpoints) - Full smoke test verified: every endpoint returns correct data
This commit is contained in:
parent
846803500c
commit
717ec85607
|
|
@ -20,3 +20,5 @@
|
|||
## AgriAssist AI
|
||||
- Status: On hold
|
||||
- Hackathon project (slides, MVP, pitch strategy)
|
||||
|
||||
- [Test goal](goal:c1c992ef) —
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# Test Journal Entry
|
||||
This is a test entry.
|
||||
|
|
@ -16,6 +16,11 @@ const api = {
|
|||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async patch(path, body = {}) {
|
||||
const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async del(path) {
|
||||
const r = await fetch(path, { method: 'DELETE' });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
|
|
@ -47,4 +52,41 @@ const api = {
|
|||
discoverStandards: () => api.post('/api/standards/discover'),
|
||||
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
|
||||
getChatHistory: () => api.get('/api/chat/history'),
|
||||
// Kanban
|
||||
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
|
||||
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
|
||||
createKanbanTask: (data) => api.post('/api/kanban/tasks', data),
|
||||
updateKanbanTask: (id, data) => api.patch(`/api/kanban/tasks/${encodeURIComponent(id)}`, data),
|
||||
completeKanbanTask: (id, summary) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/complete`, { summary }),
|
||||
blockKanbanTask: (id, reason) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/block`, { reason }),
|
||||
unblockKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/unblock`, {}),
|
||||
addKanbanComment: (id, message) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/comments`, { message }),
|
||||
linkKanbanTasks: (parentId, childId) => api.post('/api/kanban/links', { parent_id: parentId, child_id: childId }),
|
||||
unlinkKanbanTasks: (parentId, childId) => api.del(`/api/kanban/links?parent_id=${encodeURIComponent(parentId)}&child_id=${encodeURIComponent(childId)}`),
|
||||
dispatchKanban: () => api.post('/api/kanban/dispatch', {}),
|
||||
specifyKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/specify`, {}),
|
||||
decomposeKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/decompose`, {}),
|
||||
// Goals
|
||||
getGoals: () => api.get('/api/goals'),
|
||||
createGoal: (data) => api.post('/api/goals', data),
|
||||
updateGoal: (id, data) => api.put(`/api/goals/${encodeURIComponent(id)}`, data),
|
||||
deleteGoal: (id) => api.del(`/api/goals/${encodeURIComponent(id)}`),
|
||||
// Journal
|
||||
getJournalEntries: () => api.get('/api/journal/entries'),
|
||||
getJournalEntry: (date) => api.get(`/api/journal/entries/${encodeURIComponent(date)}`),
|
||||
saveJournalEntry: (date, content) => api.put(`/api/journal/entries/${encodeURIComponent(date)}`, { content }),
|
||||
searchJournal: (query) => api.get(`/api/journal/search?q=${encodeURIComponent(query)}`),
|
||||
// Agent Health
|
||||
getAgentHealth: () => api.get('/api/agents/health'),
|
||||
getAgentStats: (name) => api.get(`/api/agents/${encodeURIComponent(name)}/stats`),
|
||||
refreshAgentHealth: () => api.post('/api/agents/health/refresh', {}),
|
||||
// Smart Router
|
||||
suggestRouter: (task) => api.post('/api/router/suggest', { task }),
|
||||
routeTask: (task, agent) => api.post('/api/router/route', { task, agent }),
|
||||
// Learning Analytics
|
||||
getSkillAnalytics: () => api.get('/api/analytics/skills'),
|
||||
getTrendAnalytics: () => api.get('/api/analytics/trends'),
|
||||
// Session Replay
|
||||
listSessions: () => api.get('/api/sessions/list'),
|
||||
getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@
|
|||
<a href="#memory" class="nav-item" data-page="memory"><span class="nav-icon">🧠</span><span class="nav-label">Memory</span></a>
|
||||
<a href="#scheduler" class="nav-item" data-page="scheduler"><span class="nav-icon">⏱</span><span class="nav-label">Scheduler</span></a>
|
||||
<a href="#audit" class="nav-item" data-page="audit"><span class="nav-icon">📋</span><span class="nav-label">Audit</span></a>
|
||||
<div class="sidebar-section"><div class="sidebar-section-label">Workflow</div></div>
|
||||
<a href="#kanban" class="nav-item" data-page="kanban"><span class="nav-icon">📌</span><span class="nav-label">Kanban Board</span></a>
|
||||
<a href="#goals" class="nav-item" data-page="goals"><span class="nav-icon">🎯</span><span class="nav-label">Goals</span></a>
|
||||
<a href="#journal" class="nav-item" data-page="journal"><span class="nav-icon">📓</span><span class="nav-label">Journal</span></a>
|
||||
<div class="sidebar-section"><div class="sidebar-section-label">Monitoring</div></div>
|
||||
<a href="#agent-health" class="nav-item" data-page="agent-health"><span class="nav-icon">🏥</span><span class="nav-label">Agent Health</span><span class="nav-badge" id="agentHealthCount">0</span></a>
|
||||
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
|
||||
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
|
||||
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
|
||||
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>
|
||||
<a href="#cost" class="nav-item" data-page="cost"><span class="nav-icon">💰</span><span class="nav-label">Cost Analytics</span></a>
|
||||
<a href="#plugins" class="nav-item" data-page="plugins"><span class="nav-icon">🔌</span><span class="nav-label">Plugins</span></a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
let agentHealthInterval = null;
|
||||
|
||||
async function renderAgentHealth() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Agent Health</div>
|
||||
<div class="page-subtitle">Real-time monitoring of all 3 agents</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<label class="switch" title="Auto-refresh every 5s">
|
||||
<input type="checkbox" id="healthAutoRefresh" checked onchange="toggleHealthAutoRefresh()">
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
<span class="text-sm text-muted">Auto</span>
|
||||
<button class="btn btn-primary" onclick="refreshAgentHealth()">🔄 Refresh Now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="agentHealthCards" class="grid grid-3" style="margin-bottom:20px">
|
||||
<div class="skeleton" style="height:180px"></div>
|
||||
<div class="skeleton" style="height:180px"></div>
|
||||
<div class="skeleton" style="height:180px"></div>
|
||||
</div>
|
||||
<div class="section-title">Health Overview</div>
|
||||
<div class="card" id="healthOverviewCard">
|
||||
<div class="loading"><div class="loading-spinner"></div><span>Loading health data...</span></div>
|
||||
</div>
|
||||
`;
|
||||
await refreshAgentHealth();
|
||||
if (document.getElementById('healthAutoRefresh')?.checked) {
|
||||
startHealthAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function startHealthAutoRefresh() {
|
||||
stopHealthAutoRefresh();
|
||||
agentHealthInterval = setInterval(refreshAgentHealth, 5000);
|
||||
}
|
||||
|
||||
function stopHealthAutoRefresh() {
|
||||
if (agentHealthInterval) {
|
||||
clearInterval(agentHealthInterval);
|
||||
agentHealthInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHealthAutoRefresh() {
|
||||
if (document.getElementById('healthAutoRefresh')?.checked) {
|
||||
startHealthAutoRefresh();
|
||||
} else {
|
||||
stopHealthAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgentHealth() {
|
||||
try {
|
||||
const data = await api.getAgentHealth();
|
||||
const agents = data.agents || [];
|
||||
const cards = document.getElementById('agentHealthCards');
|
||||
if (!cards) return;
|
||||
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
|
||||
const agentColors = { opencode: 'purple', hermes: 'green', gemini: 'blue' };
|
||||
cards.innerHTML = agents.map(a => `
|
||||
<div class="agent-health-card">
|
||||
<div class="agent-health-avatar" style="background:var(--${agentColors[a.name] || 'accent'}-dim);color:var(--${agentColors[a.name] || 'accent'})">
|
||||
${agentIcons[a.name] || '🤖'}
|
||||
</div>
|
||||
<div class="agent-health-info">
|
||||
<div class="agent-health-name" style="text-transform:capitalize">${a.name}</div>
|
||||
<div class="agent-health-status">
|
||||
<span class="agent-dot ${a.status === 'online' ? 'online' : a.status === 'warning' ? 'warning' : 'offline'}"></span>
|
||||
<span style="text-transform:capitalize;color:var(--text-secondary)">${a.status}</span>
|
||||
</div>
|
||||
<div class="agent-health-stats">
|
||||
<div class="agent-health-stat">
|
||||
<div class="agent-health-stat-value" style="color:var(--green)">${a.status === 'online' ? '100' : '0'}%</div>
|
||||
<div class="agent-health-stat-label">Uptime</div>
|
||||
</div>
|
||||
<div class="agent-health-stat">
|
||||
<div class="agent-health-stat-value" style="color:var(--accent-light)">${a.status === 'online' ? '✓' : '✗'}</div>
|
||||
<div class="agent-health-stat-label">Reachable</div>
|
||||
</div>
|
||||
<div class="agent-health-stat">
|
||||
<div class="agent-health-stat-value text-sm" style="font-size:11px;color:var(--text-muted)">${new Date(data.updated).toLocaleTimeString()}</div>
|
||||
<div class="agent-health-stat-label">Last Check</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
const overview = document.getElementById('healthOverviewCard');
|
||||
if (overview) {
|
||||
const online = agents.filter(a => a.status === 'online').length;
|
||||
const total = agents.length;
|
||||
overview.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600">System Status</div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
|
||||
${online}/${total} agents online · Last updated: ${new Date(data.updated).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-indicator ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}">
|
||||
<span class="agent-dot ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}"></span>
|
||||
${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
const cards = document.getElementById('agentHealthCards');
|
||||
if (cards) cards.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load health data</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
async function renderGoals() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Goals</div>
|
||||
<div class="page-subtitle">Project targets, task lists, and progress tracking</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="showCreateGoalModal()">+ New Goal</button>
|
||||
<button class="btn btn-ghost" onclick="renderGoals()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3" style="margin-bottom:16px">
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="goalTotalCount">0</div>
|
||||
<div class="metric-tile-label">Total Goals</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="goalActiveCount">0</div>
|
||||
<div class="metric-tile-label">Active</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="goalCompleteCount">0</div>
|
||||
<div class="metric-tile-label">Completed</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="goalAvgProgress">0%</div>
|
||||
<div class="metric-tile-label">Avg Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-3" id="goalList"></div>
|
||||
`;
|
||||
try {
|
||||
const data = await api.getGoals();
|
||||
const goals = data.goals || [];
|
||||
const list = document.getElementById('goalList');
|
||||
const totalEl = document.getElementById('goalTotalCount');
|
||||
const activeEl = document.getElementById('goalActiveCount');
|
||||
const completeEl = document.getElementById('goalCompleteCount');
|
||||
const avgEl = document.getElementById('goalAvgProgress');
|
||||
if (!list) return;
|
||||
if (goals.length === 0) {
|
||||
list.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">🎯</div><div class="empty-state-title">No goals yet</div><div class="empty-state-desc">Create your first goal to start tracking progress</div></div>`;
|
||||
if (totalEl) totalEl.textContent = '0';
|
||||
if (activeEl) activeEl.textContent = '0';
|
||||
if (completeEl) completeEl.textContent = '0';
|
||||
if (avgEl) avgEl.textContent = '0%';
|
||||
return;
|
||||
}
|
||||
const active = goals.filter(g => g.status === 'active').length;
|
||||
const done = goals.filter(g => g.status === 'completed').length;
|
||||
const avgProg = Math.round(goals.reduce((s, g) => s + (g.progress || 0), 0) / goals.length);
|
||||
if (totalEl) totalEl.textContent = goals.length;
|
||||
if (activeEl) activeEl.textContent = active;
|
||||
if (completeEl) completeEl.textContent = done;
|
||||
if (avgEl) avgEl.textContent = avgProg + '%';
|
||||
list.innerHTML = goals.map(g => `
|
||||
<div class="goal-card">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span class="badge badge-${g.status === 'completed' ? 'success' : g.status === 'active' ? 'info' : 'warning'}">${g.status}</span>
|
||||
<span class="badge badge-accent">${g.category}</span>
|
||||
${g.target_date ? `<span class="text-muted text-xs">🎯 ${g.target_date}</span>` : ''}
|
||||
</div>
|
||||
<div class="goal-card-title">${escapeHtml(g.title)}</div>
|
||||
${g.description ? `<div class="text-muted text-sm" style="margin-bottom:8px">${escapeHtml(g.description)}</div>` : ''}
|
||||
<div class="goal-card-progress">
|
||||
<div class="goal-card-progress-bar"><div class="goal-card-progress-fill" style="width:${g.progress || 0}%"></div></div>
|
||||
<div class="goal-card-progress-text">${g.progress || 0}%</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">
|
||||
<button class="btn btn-sm ${g.status !== 'completed' ? 'btn-primary' : 'btn-ghost'}" onclick="updateGoalProgress('${g.id}', ${Math.min((g.progress || 0) + 25, 100)})">
|
||||
${g.status !== 'completed' ? '+25%' : '✅ Done'}
|
||||
</button>
|
||||
${g.status !== 'completed' ? `<button class="btn btn-sm btn-ghost" onclick="completeGoal('${g.id}')">Mark Complete</button>` : ''}
|
||||
<button class="btn btn-sm btn-ghost" onclick="deleteGoal('${g.id}')" style="margin-left:auto;color:var(--red)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
showToast('Failed to load goals: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateGoalModal() {
|
||||
const modal = document.getElementById('modalContainer');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Create New Goal</div>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input class="form-input" id="goalTitle" placeholder="e.g., Complete CloudMart Phase 2">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-textarea" id="goalDesc" placeholder="What does this goal involve?" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" id="goalCategory">
|
||||
<option value="general">General</option>
|
||||
<option value="development">Development</option>
|
||||
<option value="study">Study</option>
|
||||
<option value="devops">DevOps</option>
|
||||
<option value="personal">Personal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Target Date</label>
|
||||
<input class="form-input" id="goalDate" type="date">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createGoal()">Create Goal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function createGoal() {
|
||||
const title = document.getElementById('goalTitle').value.trim();
|
||||
if (!title) { showToast('Title is required', 'error'); return; }
|
||||
try {
|
||||
await api.createGoal({
|
||||
title,
|
||||
description: document.getElementById('goalDesc').value.trim(),
|
||||
category: document.getElementById('goalCategory').value,
|
||||
target_date: document.getElementById('goalDate').value,
|
||||
});
|
||||
showToast('Goal created!', 'success');
|
||||
closeModal();
|
||||
renderGoals();
|
||||
} catch (err) {
|
||||
showToast('Failed to create goal: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGoalProgress(id, progress) {
|
||||
try {
|
||||
const status = progress >= 100 ? 'completed' : 'active';
|
||||
await api.updateGoal(id, { progress, status });
|
||||
renderGoals();
|
||||
} catch (err) {
|
||||
showToast('Failed to update goal: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function completeGoal(id) {
|
||||
try {
|
||||
await api.updateGoal(id, { progress: 100, status: 'completed' });
|
||||
showToast('Goal completed! 🎉', 'success');
|
||||
renderGoals();
|
||||
} catch (err) {
|
||||
showToast('Failed to complete goal: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGoal(id) {
|
||||
if (!confirm('Delete this goal?')) return;
|
||||
try {
|
||||
await api.deleteGoal(id);
|
||||
showToast('Goal deleted', 'info');
|
||||
renderGoals();
|
||||
} catch (err) {
|
||||
showToast('Failed to delete goal: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
let journalCurrentDate = '';
|
||||
|
||||
async function renderJournal() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
journalCurrentDate = today;
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Journal</div>
|
||||
<div class="page-subtitle">Daily entries and notes — auto-saved to brain/journal/</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<input class="form-input" type="date" id="journalDatePicker" value="${today}" onchange="loadJournalEntry(this.value)" style="width:160px">
|
||||
<button class="btn btn-ghost" onclick="renderJournal()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3" style="margin-bottom:16px">
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="journalTotalCount">0</div>
|
||||
<div class="metric-tile-label">Total Entries</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="journalWordCount">0</div>
|
||||
<div class="metric-tile-label">Words Today</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1">
|
||||
<div class="metric-tile-value" id="journalStreak">0</div>
|
||||
<div class="metric-tile-label">Day Streak</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3" style="margin-bottom:16px">
|
||||
<div style="flex:1;display:flex;gap:8px">
|
||||
<input class="form-input" id="journalSearchInput" placeholder="Search entries..." style="flex:1">
|
||||
<button class="btn btn-ghost" onclick="searchJournal()">🔍 Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="journal-entry" id="journalEditor">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<div style="font-weight:600;font-size:14px" id="journalDateLabel">${formatDateDisplay(today)}</div>
|
||||
<div>
|
||||
<span class="badge badge-info" id="journalSaveStatus">Auto-save on</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="journalText" placeholder="Write your daily entry here... Markdown supported." oninput="scheduleJournalSave()"></textarea>
|
||||
</div>
|
||||
<div id="journalEntriesList" style="margin-top:16px"></div>
|
||||
`;
|
||||
loadJournalEntry(today);
|
||||
loadJournalEntries();
|
||||
}
|
||||
|
||||
function formatDateDisplay(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
let journalSaveTimer = null;
|
||||
function scheduleJournalSave() {
|
||||
if (journalSaveTimer) clearTimeout(journalSaveTimer);
|
||||
journalSaveTimer = setTimeout(saveJournalEntry, 2000);
|
||||
const status = document.getElementById('journalSaveStatus');
|
||||
if (status) status.textContent = 'Unsaved changes...';
|
||||
}
|
||||
|
||||
async function saveJournalEntry() {
|
||||
const textarea = document.getElementById('journalText');
|
||||
if (!textarea) return;
|
||||
const content = textarea.value;
|
||||
const date = journalCurrentDate;
|
||||
try {
|
||||
await api.saveJournalEntry(date, content);
|
||||
const status = document.getElementById('journalSaveStatus');
|
||||
if (status) status.textContent = 'Saved ✓';
|
||||
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
|
||||
const wc = document.getElementById('journalWordCount');
|
||||
if (wc) wc.textContent = words;
|
||||
} catch (err) {
|
||||
const status = document.getElementById('journalSaveStatus');
|
||||
if (status) status.textContent = 'Save failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJournalEntry(date) {
|
||||
journalCurrentDate = date;
|
||||
const label = document.getElementById('journalDateLabel');
|
||||
if (label) label.textContent = formatDateDisplay(date);
|
||||
const textarea = document.getElementById('journalText');
|
||||
if (!textarea) return;
|
||||
try {
|
||||
const data = await api.getJournalEntry(date);
|
||||
textarea.value = data.content || '';
|
||||
const words = data.content ? data.content.trim().split(/\s+/).length : 0;
|
||||
const wc = document.getElementById('journalWordCount');
|
||||
if (wc) wc.textContent = words;
|
||||
const status = document.getElementById('journalSaveStatus');
|
||||
if (status) status.textContent = 'Loaded ✓';
|
||||
} catch (err) {
|
||||
textarea.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJournalEntries() {
|
||||
try {
|
||||
const data = await api.getJournalEntries();
|
||||
const entries = data.entries || [];
|
||||
const totalEl = document.getElementById('journalTotalCount');
|
||||
if (totalEl) totalEl.textContent = entries.length;
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const ds = d.toISOString().split('T')[0];
|
||||
if (entries.some(e => e.date === ds)) { streak++; }
|
||||
else if (i > 0) break;
|
||||
}
|
||||
const streakEl = document.getElementById('journalStreak');
|
||||
if (streakEl) streakEl.textContent = streak;
|
||||
const list = document.getElementById('journalEntriesList');
|
||||
if (!list) return;
|
||||
if (entries.length === 0) {
|
||||
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📓</div><div class="empty-state-title">No journal entries yet</div><div class="empty-state-desc">Write your first entry above</div></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `
|
||||
<div class="section-title">Recent Entries</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
${entries.slice(0, 30).map(e => `
|
||||
<button class="btn btn-sm ${e.date === journalCurrentDate ? 'btn-primary' : 'btn-ghost'}" onclick="loadJournalEntry('${e.date}');document.getElementById('journalDatePicker').value='${e.date}'">
|
||||
${e.date}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
showToast('Failed to load entries: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function searchJournal() {
|
||||
const q = document.getElementById('journalSearchInput').value.trim();
|
||||
if (!q) { loadJournalEntries(); return; }
|
||||
try {
|
||||
const data = await api.searchJournal(q);
|
||||
const results = data.results || [];
|
||||
const list = document.getElementById('journalEntriesList');
|
||||
if (!list) return;
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">No results for "${escapeHtml(q)}"</div></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `
|
||||
<div class="section-title">Results for "${escapeHtml(q)}"</div>
|
||||
${results.map(r => `
|
||||
<div class="card" style="cursor:pointer;margin-bottom:8px" onclick="loadJournalEntry('${r.date}');document.getElementById('journalDatePicker').value='${r.date}'">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${r.date}</div>
|
||||
<div class="text-muted text-sm">${escapeHtml(r.preview)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
} catch (err) {
|
||||
showToast('Search failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
let kanbanData = null;
|
||||
|
||||
async function renderKanban() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Kanban Board</div>
|
||||
<div class="page-subtitle">Visual task management — track, prioritize, and organize work</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="showAddKanbanTask()">+ Add Task</button>
|
||||
<button class="btn btn-ghost" onclick="renderKanban()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-toolbar">
|
||||
<input class="form-input" id="kanbanFilterInput" placeholder="Filter tasks..." oninput="filterKanbanTasks()" style="flex:1;max-width:280px">
|
||||
<select class="form-select" id="kanbanFilterPriority" onchange="filterKanbanTasks()" style="width:120px">
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<select class="form-select" id="kanbanFilterCategory" onchange="filterKanbanTasks()" style="width:140px">
|
||||
<option value="all">All Categories</option>
|
||||
<option value="development">Development</option>
|
||||
<option value="devops">DevOps</option>
|
||||
<option value="study">Study</option>
|
||||
<option value="content">Content</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kanban-board" id="kanbanBoard">
|
||||
<div class="skeleton" style="height:400px"></div>
|
||||
</div>
|
||||
`;
|
||||
await loadKanbanData();
|
||||
}
|
||||
|
||||
async function loadKanbanData() {
|
||||
try {
|
||||
const data = await api.getKanbanBoard();
|
||||
kanbanData = data;
|
||||
renderKanbanBoard();
|
||||
} catch (err) {
|
||||
const board = document.getElementById('kanbanBoard');
|
||||
if (board) board.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-title">Failed to load kanban</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderKanbanBoard() {
|
||||
const board = document.getElementById('kanbanBoard');
|
||||
if (!board || !kanbanData) return;
|
||||
const columnsObj = kanbanData.columns || {};
|
||||
const columns = Object.keys(columnsObj);
|
||||
const allTasks = kanbanData.tasks || Object.values(columnsObj).flat();
|
||||
const filterText = (document.getElementById('kanbanFilterInput')?.value || '').toLowerCase();
|
||||
const filterPriority = document.getElementById('kanbanFilterPriority')?.value || 'all';
|
||||
const filterCategory = document.getElementById('kanbanFilterCategory')?.value || 'all';
|
||||
const columnLabels = { triage: 'Triage', todo: 'To Do', ready: 'Ready', in_progress: 'In Progress', blocked: 'Blocked', done: 'Done', backlog: 'Backlog', review: 'Review' };
|
||||
const columnIcons = { triage: '🔍', todo: '📝', ready: '✅', in_progress: '🔄', blocked: '🚫', done: '🎉', backlog: '📋', review: '🔍' };
|
||||
board.innerHTML = columns.map(col => {
|
||||
let colTasks = (columnsObj[col] || []).filter(t => {
|
||||
if (filterText) return (t.title || '').toLowerCase().includes(filterText) || (t.body || t.description || '').toLowerCase().includes(filterText);
|
||||
return true;
|
||||
}).filter(t => filterPriority === 'all' || (t.priority || 'medium') === filterPriority)
|
||||
.filter(t => filterCategory === 'all' || (t.category || 'general') === filterCategory);
|
||||
return `
|
||||
<div class="kanban-column" data-column="${col}">
|
||||
<div class="kanban-column-header">
|
||||
<div class="kanban-column-title">
|
||||
<span>${columnIcons[col] || '📌'}</span>
|
||||
${columnLabels[col] || col}
|
||||
<span class="kanban-count">${colTasks.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-column-body" ondragover="event.preventDefault()" ondrop="onKanbanDrop(event, '${col}')">
|
||||
${colTasks.length === 0 ? `<div class="kanban-empty">No tasks</div>` :
|
||||
colTasks.map(t => `
|
||||
<div class="kanban-card" draggable="true" ondragstart="onKanbanDrag(event, '${t.id}')" onclick="showKanbanDetail('${t.id}')">
|
||||
<div class="kanban-card-header">
|
||||
<span class="kanban-priority priority-${t.priority || 'medium'}">${t.priority || 'medium'}</span>
|
||||
</div>
|
||||
<div class="kanban-card-title">${escapeHtml(t.title)}</div>
|
||||
${t.body ? `<div class="kanban-card-desc">${escapeHtml(t.body.substring(0, 80))}${t.body.length > 80 ? '...' : ''}</div>` : ''}
|
||||
<div class="kanban-card-meta">
|
||||
${t.assignee ? `<span>👤 ${escapeHtml(t.assignee)}</span>` : ''}
|
||||
</div>
|
||||
${t.status === 'blocked' ? `<div class="kanban-blocked-badge">🚫 Blocked</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showAddKanbanTask() {
|
||||
const modal = document.getElementById('modalContainer');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal" style="max-width:520px">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Add Kanban Task</div>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input class="form-input" id="kanbanTitle" placeholder="e.g., Implement login page">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-textarea" id="kanbanDescription" rows="2" placeholder="Brief description"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" id="kanbanStatus">
|
||||
<option value="triage">Triage</option>
|
||||
<option value="todo">To Do</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Priority</label>
|
||||
<select class="form-select" id="kanbanPriority">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" id="kanbanCategory">
|
||||
<option value="general">General</option>
|
||||
<option value="development">Development</option>
|
||||
<option value="devops">DevOps</option>
|
||||
<option value="study">Study</option>
|
||||
<option value="content">Content</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Assigned To</label>
|
||||
<input class="form-input" id="kanbanAssigned" placeholder="e.g., opencode" style="text-transform:lowercase">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Target Date</label>
|
||||
<input class="form-input" id="kanbanDate" type="date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tags (comma separated)</label>
|
||||
<input class="form-input" id="kanbanTags" placeholder="e.g., frontend, api">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createKanbanTask()">Add Task</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function createKanbanTask() {
|
||||
const title = document.getElementById('kanbanTitle').value.trim();
|
||||
if (!title) { showToast('Title is required', 'error'); return; }
|
||||
const tagsStr = document.getElementById('kanbanTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
try {
|
||||
await api.createKanbanTask({
|
||||
title,
|
||||
body: document.getElementById('kanbanDescription').value.trim(),
|
||||
status: document.getElementById('kanbanStatus').value,
|
||||
priority: document.getElementById('kanbanPriority').value,
|
||||
assignee: document.getElementById('kanbanAssigned').value.trim(),
|
||||
});
|
||||
showToast('Task added to kanban board!', 'success');
|
||||
closeModal();
|
||||
renderKanban();
|
||||
} catch (err) {
|
||||
showToast('Failed to create task: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
let kanbanDraggedId = null;
|
||||
function onKanbanDrag(e, id) {
|
||||
kanbanDraggedId = id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
async function onKanbanDrop(e, status) {
|
||||
e.preventDefault();
|
||||
if (!kanbanDraggedId) return;
|
||||
try {
|
||||
await api.updateKanbanTask(kanbanDraggedId, { status });
|
||||
kanbanDraggedId = null;
|
||||
renderKanban();
|
||||
} catch (err) {
|
||||
showToast('Failed to move task: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showKanbanDetail(id) {
|
||||
const task = kanbanData?.tasks?.find(t => t.id === id);
|
||||
if (!task) return;
|
||||
const modal = document.getElementById('modalContainer');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">${escapeHtml(task.title)}</div>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<span class="badge badge-${task.status === 'done' ? 'success' : task.status === 'in_progress' ? 'info' : 'warning'}">${task.status}</span>
|
||||
<span class="kanban-priority priority-${task.priority || 'medium'}">${task.priority}</span>
|
||||
${task.status === 'blocked' ? `<span class="badge badge-danger">🚫 Blocked</span>` : ''}
|
||||
</div>
|
||||
${task.body ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${escapeHtml(task.body)}</div>` : ''}
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;font-size:12px">
|
||||
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong></span>` : ''}
|
||||
<span>📅 <strong>${task.created || 'N/A'}</strong></span>
|
||||
${task.completed_at ? `<span>✅ <strong>${task.completed_at}</strong></span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
${task.status !== 'done' ? `<button class="btn btn-sm btn-primary" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
|
||||
${task.status !== 'blocked' ? `<button class="btn btn-sm btn-ghost" onclick="blockKanbanTask('${task.id}')">🚫 Block</button>` : `<button class="btn btn-sm btn-ghost" onclick="unblockKanbanTask('${task.id}')">🔓 Unblock</button>`}
|
||||
<button class="btn btn-sm btn-ghost" onclick="deleteKanbanTask('${task.id}')" style="color:var(--red)">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function completeKanbanTask(id) {
|
||||
try {
|
||||
await api.completeKanbanTask(id);
|
||||
closeModal();
|
||||
renderKanban();
|
||||
showToast('Task completed! ✅', 'success');
|
||||
} catch (err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function blockKanbanTask(id) {
|
||||
try {
|
||||
await api.blockKanbanTask(id);
|
||||
closeModal();
|
||||
renderKanban();
|
||||
showToast('Task blocked 🚫', 'warning');
|
||||
} catch (err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unblockKanbanTask(id) {
|
||||
try {
|
||||
await api.unblockKanbanTask(id);
|
||||
closeModal();
|
||||
renderKanban();
|
||||
showToast('Task unblocked 🔓', 'success');
|
||||
} catch (err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKanbanTask(id) {
|
||||
if (!confirm('Delete this task?')) return;
|
||||
try {
|
||||
await api.deleteKanbanTask(id);
|
||||
closeModal();
|
||||
renderKanban();
|
||||
showToast('Task deleted', 'info');
|
||||
} catch (err) {
|
||||
showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function filterKanbanTasks() {
|
||||
renderKanbanBoard();
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
async function renderLearningAnalytics() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Learning Analytics</div>
|
||||
<div class="page-subtitle">Skill evaluation scores and performance trends</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-ghost" onclick="renderLearningAnalytics()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Skill Scores</div>
|
||||
<div id="skillScoresGrid" class="grid grid-3" style="margin-bottom:20px">
|
||||
<div class="skeleton" style="height:100px"></div>
|
||||
<div class="skeleton" style="height:100px"></div>
|
||||
<div class="skeleton" style="height:100px"></div>
|
||||
</div>
|
||||
<div class="section-title">Score Trends</div>
|
||||
<div id="trendCharts" class="grid grid-2"></div>
|
||||
<div class="section-title">Skill Details</div>
|
||||
<div id="skillDetailsList"></div>
|
||||
`;
|
||||
try {
|
||||
const [skillData, trendData] = await Promise.all([api.getSkillAnalytics(), api.getTrendAnalytics()]);
|
||||
const skills = skillData.skills || [];
|
||||
const trends = trendData.trends || {};
|
||||
const grid = document.getElementById('skillScoresGrid');
|
||||
if (grid) {
|
||||
if (skills.length === 0) {
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">📊</div><div class="empty-state-title">No skill data yet</div><div class="empty-state-desc">Skill evaluations will appear here as they accumulate</div></div>`;
|
||||
} else {
|
||||
const topSkills = skills.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 6);
|
||||
grid.innerHTML = topSkills.map(s => {
|
||||
const pct = Math.round((s.score || 0) * 100);
|
||||
return `
|
||||
<div class="card chart-card" style="position:relative">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px;text-transform:capitalize">${escapeHtml(s.name)}</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">${s.evals || 0} evaluations</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="flex:1;height:6px;background:var(--bg-dim);border-radius:99px;overflow:hidden">
|
||||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,var(--accent),var(--accent-light));border-radius:99px;transition:width 0.6s ease"></div>
|
||||
</div>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--accent-light)">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
const trendSection = document.getElementById('trendCharts');
|
||||
if (trendSection) {
|
||||
const trendEntries = Object.entries(trends).slice(0, 4);
|
||||
if (trendEntries.length === 0) {
|
||||
trendSection.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">📈</div><div class="empty-state-title">No trend data</div><div class="empty-state-desc">Trends will appear after multiple evaluations</div></div>`;
|
||||
} else {
|
||||
trendSection.innerHTML = trendEntries.map(([name, history]) => {
|
||||
const vals = (history || []).slice(-10);
|
||||
const maxVal = Math.max(...vals, 0.01);
|
||||
const chartHeight = 120;
|
||||
return `
|
||||
<div class="card chart-card">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;text-transform:capitalize">${escapeHtml(name)}</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:3px;height:${chartHeight}px;padding-top:20px">
|
||||
${vals.map((v, i) => {
|
||||
const h = (v / maxVal) * (chartHeight - 25);
|
||||
return `<div style="flex:1;height:${h}px;background:linear-gradient(180deg,var(--accent),var(--accent-light));border-radius:3px 3px 0 0;min-height:4px;transition:height 0.3s ease" title="${(v * 100).toFixed(0)}%"></div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:4px">
|
||||
<span class="text-xs text-muted">${vals.length} data points</span>
|
||||
<span class="text-xs text-muted">Avg: ${(vals.reduce((a, b) => a + b, 0) / vals.length * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
const details = document.getElementById('skillDetailsList');
|
||||
if (details) {
|
||||
if (skills.length === 0) {
|
||||
details.innerHTML = '';
|
||||
} else {
|
||||
details.innerHTML = skills.map(s => {
|
||||
const pct = Math.round((s.score || 0) * 100);
|
||||
return `
|
||||
<div class="card" style="margin-bottom:8px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-weight:600;text-transform:capitalize">${escapeHtml(s.name)}</div>
|
||||
<div class="text-muted text-sm">${s.evals || 0} evaluations · Best: ${((s.best || 0) * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
<span style="font-size:16px;font-weight:700;color:${pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--yellow)' : 'var(--red)'}">${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to load analytics: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
async function renderSessionReplay() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Session Replay</div>
|
||||
<div class="page-subtitle">Browse and replay past opencode sessions</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-ghost" onclick="renderSessionReplay()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionList">
|
||||
<div class="loading"><div class="loading-spinner"></div><span>Loading sessions...</span></div>
|
||||
</div>
|
||||
<div id="sessionDetail" style="margin-top:16px"></div>
|
||||
`;
|
||||
try {
|
||||
const data = await api.listSessions();
|
||||
const sessions = data.sessions || [];
|
||||
const list = document.getElementById('sessionList');
|
||||
if (sessions.length === 0) {
|
||||
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🎬</div><div class="empty-state-title">No sessions found</div><div class="empty-state-desc">Sessions from opencode will appear here</div></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `
|
||||
<div class="card" style="padding:0">
|
||||
<table>
|
||||
<tr><th>Session ID</th><th>Date</th><th>Size</th><th></th></tr>
|
||||
${sessions.slice(0, 50).map(s => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:12px">${escapeHtml(s.id)}</td>
|
||||
<td class="text-sm">${new Date(s.date).toLocaleString()}</td>
|
||||
<td class="text-sm text-muted">${formatSize(s.size || 0)}</td>
|
||||
<td><button class="btn btn-sm btn-ghost" onclick="replaySession('${s.id}')">▶ Replay</button></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
${sessions.length > 50 ? `<div style="padding:12px;text-align:center;color:var(--text-muted);font-size:12px">Showing 50 of ${sessions.length} sessions</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
document.getElementById('sessionList').innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load sessions</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function replaySession(id) {
|
||||
const detail = document.getElementById('sessionDetail');
|
||||
detail.innerHTML = `<div class="loading"><div class="loading-spinner"></div><span>Loading session ${escapeHtml(id)}...</span></div>`;
|
||||
try {
|
||||
const data = await api.getSessionReplay(id);
|
||||
const messages = data.messages || [];
|
||||
const session = data.session || {};
|
||||
detail.innerHTML = `
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-weight:600">Session: <code style="font-size:12px">${escapeHtml(id)}</code></div>
|
||||
<div class="text-muted text-sm">${messages.length} messages · ${new Date(session.created_at || Date.now()).toLocaleString()}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" onclick="document.getElementById('sessionDetail').innerHTML=''">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;max-height:600px;overflow-y:auto;padding-right:8px">
|
||||
${messages.map(m => {
|
||||
const isUser = m.role === 'user' || m.role === 'human';
|
||||
return `
|
||||
<div class="session-message ${isUser ? 'session-message-user' : 'session-message-assistant'}">
|
||||
<div class="session-message-role">${isUser ? '👤 You' : '🤖 Assistant'}</div>
|
||||
<div class="session-message-content">${escapeHtml((m.content || '').substring(0, 500))}${(m.content || '').length > 500 ? '...' : ''}</div>
|
||||
<div class="session-message-meta">${new Date(m.timestamp || Date.now()).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
${messages.length === 0 ? '<div class="text-muted text-sm" style="text-align:center;padding:20px">Empty session</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
detail.innerHTML = `<div class="card" style="border-color:var(--red)"><div class="text-sm" style="color:var(--red)">⚠ Failed to replay session: ${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
async function renderSmartRouter() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Smart Router</div>
|
||||
<div class="page-subtitle">Intelligent task routing — auto-suggest or manually pick an agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Route a Task</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Describe your task</label>
|
||||
<textarea class="form-textarea" id="routerTaskInput" placeholder="e.g., Deploy the CloudMart infrastructure to GCP..." rows="3"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-3" style="align-items:flex-end">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Route to Agent</label>
|
||||
<select class="form-select" id="routerAgentSelect">
|
||||
<option value="auto">🤖 Auto (AI suggests)</option>
|
||||
<option value="opencode">🔧 opencode (Code/DevOps)</option>
|
||||
<option value="hermes">⚡ Hermes (Memory/Scheduling)</option>
|
||||
<option value="gemini">🧠 Gemini CLI (Research/Analysis)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="suggestRouter()" style="margin-bottom:16px">🤖 Suggest Agent</button>
|
||||
<button class="btn btn-gradient" onclick="routeTask()" style="margin-bottom:16px">🚀 Route Task</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="routerResult"></div>
|
||||
<div class="section-title" style="margin-top:20px">Routing Rules</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>Agent</th><th>Best For</th><th>Keywords</th></tr>
|
||||
<tr><td><strong>🔧 opencode</strong></td><td>Code, DevOps, infra, git, file operations</td><td class="text-muted text-sm">code, deploy, git, terraform, docker, test, build, script</td></tr>
|
||||
<tr><td><strong>⚡ Hermes</strong></td><td>Memory, scheduling, messaging, skills</td><td class="text-muted text-sm">memory, schedule, cron, reminder, brain, plugin, backup</td></tr>
|
||||
<tr><td><strong>🧠 Gemini CLI</strong></td><td>Research, analysis, study, document, review</td><td class="text-muted text-sm">research, analyze, search, explain, study, learn, report</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function suggestRouter() {
|
||||
const task = document.getElementById('routerTaskInput').value.trim();
|
||||
if (!task) { showToast('Describe your task first', 'warning'); return; }
|
||||
const btn = document.querySelector('button[onclick="suggestRouter()"]');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ Thinking...'; }
|
||||
try {
|
||||
const data = await api.suggestRouter(task);
|
||||
const result = document.getElementById('routerResult');
|
||||
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
|
||||
const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
|
||||
result.innerHTML = `
|
||||
<div class="card" style="border-color:${confidenceColors[data.confidence] || 'var(--border)'};margin-bottom:12px">
|
||||
<div class="router-suggestion" style="border:none;padding:0;background:none">
|
||||
<div>
|
||||
<div class="router-suggestion-agent" style="font-size:18px">
|
||||
${agentIcons[data.suggested_agent] || '🤖'} ${data.suggested_agent}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
|
||||
Confidence: <span style="color:${confidenceColors[data.confidence] || 'var(--text-muted)'}">${data.confidence}</span>
|
||||
${data.confidence === 'high' ? '✅' : data.confidence === 'medium' ? '⚠️' : '❓'}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:right">
|
||||
<span class="badge badge-accent">Best Match</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;padding-top:8px;border-top:1px solid var(--border)">
|
||||
${Object.entries(data.scores || {}).map(([agent, score]) => `
|
||||
<span class="badge ${score > 0 ? 'badge-success' : 'badge-info'}">
|
||||
${agentIcons[agent] || '🤖'} ${agent}: ${score}
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('routerAgentSelect').value = data.suggested_agent || 'auto';
|
||||
} catch (err) {
|
||||
showToast('Suggestion failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🤖 Suggest Agent'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function routeTask() {
|
||||
const task = document.getElementById('routerTaskInput').value.trim();
|
||||
if (!task) { showToast('Describe your task first', 'warning'); return; }
|
||||
let agent = document.getElementById('routerAgentSelect').value;
|
||||
if (agent === 'auto') {
|
||||
showToast('Click "Suggest Agent" first or pick an agent manually', 'warning');
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector('button[onclick="routeTask()"]');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ Routing...'; }
|
||||
try {
|
||||
const data = await api.routeTask(task, agent);
|
||||
showToast(`✅ Task routed to ${agent}`, 'success');
|
||||
const result = document.getElementById('routerResult');
|
||||
result.innerHTML += `
|
||||
<div class="card" style="margin-top:8px;border-color:var(--green)">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span style="font-size:24px">✅</span>
|
||||
<div>
|
||||
<div style="font-weight:600">Task Routed</div>
|
||||
<div class="text-muted text-sm">${data.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
showToast('Routing failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🚀 Route Task'; }
|
||||
}
|
||||
}
|
||||
|
|
@ -820,3 +820,546 @@ pre {
|
|||
.chat-sidebar { display: none; }
|
||||
.chat-message { max-width: 95%; }
|
||||
}
|
||||
|
||||
/* ─── v0.2.0 UI Modernization ─── */
|
||||
|
||||
/* Glass card variant */
|
||||
.glass-card {
|
||||
background: rgba(28, 35, 51, 0.8);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(41, 48, 66, 0.6);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
[data-theme="light"] .glass-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-color: rgba(226, 229, 240, 0.6);
|
||||
}
|
||||
.glass-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 8px 32px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
|
||||
/* Glow border utility */
|
||||
.glow-border {
|
||||
position: relative;
|
||||
}
|
||||
.glow-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, var(--accent), var(--pink), var(--accent));
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.glow-border:hover::after {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Section gradient headers */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(135deg, var(--text-secondary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Animated gradient button */
|
||||
.btn-gradient {
|
||||
background: linear-gradient(135deg, var(--accent), var(--pink));
|
||||
color: white;
|
||||
border: none;
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 3s ease infinite;
|
||||
}
|
||||
.btn-gradient:hover {
|
||||
box-shadow: 0 4px 20px rgba(108, 92, 231, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* Enhanced loading spinner */
|
||||
.loading-ring {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, var(--bg-card), var(--accent), var(--pink), var(--bg-card));
|
||||
animation: ringSpin 1s linear infinite;
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
|
||||
}
|
||||
@keyframes ringSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Metric tile */
|
||||
.metric-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.metric-tile:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
.metric-tile-value {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.metric-tile-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Timeline (for session replay) */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
}
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, var(--accent), var(--pink));
|
||||
opacity: 0.3;
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding: 8px 0 8px 16px;
|
||||
border-left: 2px solid transparent;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -22px;
|
||||
top: 14px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
.timeline-item.active::before {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.timeline-item:hover {
|
||||
border-left-color: var(--accent-glow);
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.timeline-content {
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Kanban specific */
|
||||
.kanban-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.kanban-blocked-badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; font-weight: 600;
|
||||
color: var(--red); background: var(--red-dim);
|
||||
padding: 2px 8px; border-radius: 12px; margin-top: 6px;
|
||||
}
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.kanban-column {
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
max-height: 100%;
|
||||
}
|
||||
.kanban-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kanban-column-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.kanban-column-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.kanban-column-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kanban-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.kanban-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.kanban-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.kanban-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.kanban-card-priority {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kanban-card-priority.high { background: var(--red-dim); color: var(--red); }
|
||||
.kanban-card-priority.medium { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.kanban-card-priority.low { background: var(--blue-dim); color: var(--blue); }
|
||||
|
||||
/* Goal cards */
|
||||
.goal-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.goal-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
|
||||
}
|
||||
.goal-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.goal-card-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.goal-card-progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.goal-card-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--green));
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.goal-card-progress-text {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Journal entry */
|
||||
.journal-entry {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
min-height: 300px;
|
||||
}
|
||||
.journal-entry textarea {
|
||||
width: 100%;
|
||||
min-height: 250px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
.journal-entry textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Agent health card */
|
||||
.agent-health-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.agent-health-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
.agent-health-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
.agent-health-info { flex: 1; }
|
||||
.agent-health-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.agent-health-status {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.agent-health-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.agent-health-stat {
|
||||
text-align: center;
|
||||
}
|
||||
.agent-health-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.agent-health-stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Smart router */
|
||||
.router-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.router-suggestion:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
.router-suggestion-agent {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-light);
|
||||
}
|
||||
.router-suggestion-reason {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Learning analytics chart container */
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
.chart-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Priority badges */
|
||||
.kanban-priority {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 1px 7px; border-radius: 10px;
|
||||
font-size: 9px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.priority-high { background: var(--red-dim); color: var(--red); }
|
||||
.priority-medium { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.priority-low { background: var(--green-dim); color: var(--green); }
|
||||
|
||||
/* Agent health dot */
|
||||
.agent-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
.agent-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.agent-dot.warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
||||
.agent-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||
|
||||
/* Responsive additions */
|
||||
@media (max-width: 1100px) {
|
||||
.kanban-column { min-width: 220px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.kanban-board { flex-direction: column; }
|
||||
.kanban-column { max-width: 100%; min-width: 0; max-height: 300px; }
|
||||
.agent-health-stats { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
|
||||
/* Session replay */
|
||||
.session-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.session-message-user {
|
||||
background: var(--accent-glow);
|
||||
border: 1px solid var(--accent);
|
||||
align-self: flex-end;
|
||||
margin-left: 40px;
|
||||
}
|
||||
.session-message-assistant {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
align-self: flex-start;
|
||||
margin-right: 40px;
|
||||
}
|
||||
.session-message-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.session-message-content {
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.session-message-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.empty-state-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.empty-state-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty-state-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,4 +123,11 @@ const PAGE_TITLES = {
|
|||
settings: { title: 'Settings', breadcrumb: 'Configuration' },
|
||||
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
|
||||
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
|
||||
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
|
||||
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
|
||||
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },
|
||||
'agent-health': { title: 'Agent Health', breadcrumb: 'Real-time agent monitoring' },
|
||||
'smart-router': { title: 'Smart Router', breadcrumb: 'Task routing intelligence' },
|
||||
'learning-analytics': { title: 'Learning Analytics', breadcrumb: 'Skill improvement tracking' },
|
||||
'session-replay': { title: 'Session Replay', breadcrumb: 'Conversation history playback' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
[
|
||||
{
|
||||
"id": "c1c992ef",
|
||||
"title": "Test goal",
|
||||
"description": "",
|
||||
"category": "development",
|
||||
"target_date": "",
|
||||
"status": "active",
|
||||
"progress": 0,
|
||||
"created": "2026-06-05T09:50:30.334519+00:00",
|
||||
"updated": "2026-06-05T09:50:30.334541+00:00"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "0f822987",
|
||||
"title": "Fix login bug",
|
||||
"body": "The login page has a race condition",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
"assignee": "opencode",
|
||||
"comments": [],
|
||||
"links": [],
|
||||
"created": "2026-06-05T09:52:18.236452+00:00",
|
||||
"updated": "2026-06-05T09:52:18.236473+00:00"
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"id": "8893ad14",
|
||||
"title": "Test kanban task",
|
||||
"body": "",
|
||||
"status": "blocked",
|
||||
"priority": "medium",
|
||||
"assignee": "",
|
||||
"comments": [],
|
||||
"links": [],
|
||||
"created": "2026-06-05T09:50:30.293643+00:00",
|
||||
"updated": "2026-06-05T09:52:18.274595+00:00",
|
||||
"summary": "Test done",
|
||||
"completed_at": "2026-06-05T09:50:46.473612+00:00",
|
||||
"block_reason": "Waiting for API review"
|
||||
}
|
||||
579
server.py
579
server.py
|
|
@ -631,6 +631,585 @@ def chat(req: ChatRequest):
|
|||
def get_chat_history():
|
||||
return load_chat_history()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# v0.2.0 — New Feature Endpoints
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Models ─────────────────────────────────────────────────────
|
||||
|
||||
class KanbanTaskCreate(BaseModel):
|
||||
title: str
|
||||
body: str = ""
|
||||
status: str = "triage"
|
||||
priority: str = "medium"
|
||||
assignee: str = ""
|
||||
|
||||
class KanbanTaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assignee: Optional[str] = None
|
||||
|
||||
class KanbanComplete(BaseModel):
|
||||
summary: str = ""
|
||||
|
||||
class KanbanBlock(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
class KanbanCommentCreate(BaseModel):
|
||||
message: str
|
||||
|
||||
class KanbanLinkCreate(BaseModel):
|
||||
parent_id: str
|
||||
child_id: str
|
||||
|
||||
class GoalCreate(BaseModel):
|
||||
title: str
|
||||
description: str = ""
|
||||
category: str = "general"
|
||||
target_date: str = ""
|
||||
|
||||
class GoalUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
target_date: Optional[str] = None
|
||||
progress: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class JournalSave(BaseModel):
|
||||
content: str
|
||||
|
||||
class RouterSuggest(BaseModel):
|
||||
task: str
|
||||
|
||||
class RouterRoute(BaseModel):
|
||||
task: str
|
||||
agent: str
|
||||
|
||||
# ─── Data Helpers ───────────────────────────────────────────────
|
||||
|
||||
KANBAN_DIR = BASE_DIR / "data" / "kanban"
|
||||
GOALS_FILE = BASE_DIR / "data" / "goals.json"
|
||||
JOURNAL_DIR = BASE_DIR / "brain" / "journal"
|
||||
|
||||
def ensure_dir(d: Path):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_kanban_tasks():
|
||||
ensure_dir(KANBAN_DIR)
|
||||
tasks = []
|
||||
for f in sorted(KANBAN_DIR.glob("*.json")):
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
return tasks
|
||||
|
||||
def save_kanban_task(task: dict):
|
||||
ensure_dir(KANBAN_DIR)
|
||||
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
|
||||
|
||||
def load_goals():
|
||||
if GOALS_FILE.exists():
|
||||
return json.loads(GOALS_FILE.read_text())
|
||||
return []
|
||||
|
||||
def save_goals(goals: list):
|
||||
GOALS_FILE.write_text(json.dumps(goals, indent=2))
|
||||
|
||||
# ─── Routes: Kanban Board (13 endpoints) ────────────────────────
|
||||
|
||||
@app.get("/api/kanban/board")
|
||||
def kanban_board(status: Optional[str] = None):
|
||||
try:
|
||||
tasks = load_kanban_tasks()
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.get("status") == status]
|
||||
columns = {"triage": [], "todo": [], "ready": [], "in_progress": [], "blocked": [], "done": []}
|
||||
for t in tasks:
|
||||
s = t.get("status", "triage")
|
||||
if s in columns:
|
||||
columns[s].append(t)
|
||||
return {"columns": columns, "total": len(tasks)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "columns": {}, "total": 0}
|
||||
|
||||
@app.get("/api/kanban/tasks/{task_id}")
|
||||
def kanban_get_task(task_id: str):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
return json.loads(path.read_text())
|
||||
|
||||
@app.post("/api/kanban/tasks")
|
||||
def kanban_create_task(data: KanbanTaskCreate):
|
||||
try:
|
||||
task = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"title": data.title,
|
||||
"body": data.body,
|
||||
"status": data.status,
|
||||
"priority": data.priority,
|
||||
"assignee": data.assignee,
|
||||
"comments": [],
|
||||
"links": [],
|
||||
"created": get_timestamp(),
|
||||
"updated": get_timestamp(),
|
||||
}
|
||||
save_kanban_task(task)
|
||||
append_audit({"action": "kanban_task_created", "title": data.title})
|
||||
return task
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@app.patch("/api/kanban/tasks/{task_id}")
|
||||
def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
for field in ["title", "body", "status", "priority", "assignee"]:
|
||||
val = getattr(data, field, None)
|
||||
if val is not None:
|
||||
task[field] = val
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
append_audit({"action": "kanban_task_updated", "task_id": task_id})
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/complete")
|
||||
def kanban_complete_task(task_id: str, data: KanbanComplete):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
task["status"] = "done"
|
||||
task["summary"] = data.summary
|
||||
task["completed_at"] = get_timestamp()
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
append_audit({"action": "kanban_task_completed", "task_id": task_id})
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/block")
|
||||
def kanban_block_task(task_id: str, data: KanbanBlock):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
task["status"] = "blocked"
|
||||
task["block_reason"] = data.reason
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
append_audit({"action": "kanban_task_blocked", "task_id": task_id})
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/unblock")
|
||||
def kanban_unblock_task(task_id: str):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
task["status"] = "ready"
|
||||
task["block_reason"] = ""
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
append_audit({"action": "kanban_task_unblocked", "task_id": task_id})
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/comments")
|
||||
def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
comment = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"message": data.message,
|
||||
"timestamp": get_timestamp(),
|
||||
}
|
||||
task.setdefault("comments", []).append(comment)
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/links")
|
||||
def kanban_add_link(data: KanbanLinkCreate):
|
||||
for tid in [data.parent_id, data.child_id]:
|
||||
path = KANBAN_DIR / f"{tid}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, f"Task {tid} not found")
|
||||
t = json.loads(path.read_text())
|
||||
t.setdefault("links", [])
|
||||
link = {"parent": data.parent_id, "child": data.child_id}
|
||||
if link not in t["links"]:
|
||||
t["links"].append(link)
|
||||
t["updated"] = get_timestamp()
|
||||
save_kanban_task(t)
|
||||
append_audit({"action": "kanban_link_added", "parent": data.parent_id, "child": data.child_id})
|
||||
return {"status": "linked"}
|
||||
|
||||
@app.delete("/api/kanban/links")
|
||||
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
||||
for tid in [parent_id, child_id]:
|
||||
path = KANBAN_DIR / f"{tid}.json"
|
||||
if path.exists():
|
||||
t = json.loads(path.read_text())
|
||||
t.setdefault("links", [])
|
||||
t["links"] = [l for l in t["links"] if not (l.get("parent") == parent_id and l.get("child") == child_id)]
|
||||
t["updated"] = get_timestamp()
|
||||
save_kanban_task(t)
|
||||
return {"status": "unlinked"}
|
||||
|
||||
@app.post("/api/kanban/dispatch")
|
||||
def kanban_dispatch():
|
||||
append_audit({"action": "kanban_dispatch_triggered"})
|
||||
return {"status": "dispatch_triggered", "message": "Dispatcher notified"}
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/specify")
|
||||
def kanban_specify_task(task_id: str):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
if task.get("status") == "triage":
|
||||
task["status"] = "todo"
|
||||
task["updated"] = get_timestamp()
|
||||
save_kanban_task(task)
|
||||
return task
|
||||
|
||||
@app.post("/api/kanban/tasks/{task_id}/decompose")
|
||||
def kanban_decompose_task(task_id: str):
|
||||
path = KANBAN_DIR / f"{task_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
children = []
|
||||
for i, subtask in enumerate(task.get("body", "").split("\n")):
|
||||
subtask = subtask.strip().lstrip("-* ")
|
||||
if subtask:
|
||||
child = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"title": subtask[:80],
|
||||
"body": subtask,
|
||||
"status": "todo",
|
||||
"priority": task.get("priority", "medium"),
|
||||
"assignee": "",
|
||||
"comments": [],
|
||||
"links": [{"parent": task_id, "child": ""}],
|
||||
"created": get_timestamp(),
|
||||
"updated": get_timestamp(),
|
||||
}
|
||||
child["links"][0]["child"] = child["id"]
|
||||
save_kanban_task(child)
|
||||
children.append(child)
|
||||
return {"parent": task_id, "children": children}
|
||||
|
||||
# ─── Routes: Goals (4 endpoints) ─────────────────────────────────
|
||||
|
||||
@app.get("/api/goals")
|
||||
def list_goals():
|
||||
try:
|
||||
return {"goals": load_goals()}
|
||||
except Exception as e:
|
||||
return {"goals": [], "error": str(e)}
|
||||
|
||||
@app.post("/api/goals")
|
||||
def create_goal(data: GoalCreate):
|
||||
try:
|
||||
goals = load_goals()
|
||||
goal = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"title": data.title,
|
||||
"description": data.description,
|
||||
"category": data.category,
|
||||
"target_date": data.target_date,
|
||||
"status": "active",
|
||||
"progress": 0,
|
||||
"created": get_timestamp(),
|
||||
"updated": get_timestamp(),
|
||||
}
|
||||
goals.append(goal)
|
||||
save_goals(goals)
|
||||
# Auto-sync to brain/active-projects.md
|
||||
active_path = BASE_DIR / "brain" / "active-projects.md"
|
||||
if active_path.exists():
|
||||
existing = active_path.read_text()
|
||||
existing += f"\n- [{goal['title']}](goal:{goal['id']}) — {goal['description'][:80]}\n"
|
||||
active_path.write_text(existing)
|
||||
append_audit({"action": "goal_created", "title": data.title})
|
||||
return goal
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@app.put("/api/goals/{goal_id}")
|
||||
def update_goal(goal_id: str, data: GoalUpdate):
|
||||
try:
|
||||
goals = load_goals()
|
||||
for g in goals:
|
||||
if g["id"] == goal_id:
|
||||
for field in ["title", "description", "category", "target_date", "progress", "status"]:
|
||||
val = getattr(data, field, None)
|
||||
if val is not None:
|
||||
g[field] = val
|
||||
g["updated"] = get_timestamp()
|
||||
save_goals(goals)
|
||||
return g
|
||||
raise HTTPException(404, "Goal not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@app.delete("/api/goals/{goal_id}")
|
||||
def delete_goal(goal_id: str):
|
||||
try:
|
||||
goals = load_goals()
|
||||
goals = [g for g in goals if g["id"] != goal_id]
|
||||
save_goals(goals)
|
||||
append_audit({"action": "goal_deleted", "goal_id": goal_id})
|
||||
return {"status": "deleted"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
# ─── Routes: Journal (4 endpoints) ───────────────────────────────
|
||||
|
||||
@app.get("/api/journal/entries")
|
||||
def list_journal_entries():
|
||||
try:
|
||||
ensure_dir(JOURNAL_DIR)
|
||||
entries = []
|
||||
for f in sorted(JOURNAL_DIR.glob("*.md"), reverse=True):
|
||||
entries.append({
|
||||
"date": f.stem,
|
||||
"preview": f.read_text()[:200],
|
||||
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
return {"entries": entries}
|
||||
except Exception as e:
|
||||
return {"entries": [], "error": str(e)}
|
||||
|
||||
@app.get("/api/journal/entries/{entry_date}")
|
||||
def get_journal_entry(entry_date: str):
|
||||
try:
|
||||
path = JOURNAL_DIR / f"{entry_date}.md"
|
||||
ensure_dir(JOURNAL_DIR)
|
||||
content = path.read_text() if path.exists() else ""
|
||||
return {"date": entry_date, "content": content}
|
||||
except Exception as e:
|
||||
return {"date": entry_date, "content": "", "error": str(e)}
|
||||
|
||||
@app.put("/api/journal/entries/{entry_date}")
|
||||
def save_journal_entry(entry_date: str, data: JournalSave):
|
||||
try:
|
||||
ensure_dir(JOURNAL_DIR)
|
||||
path = JOURNAL_DIR / f"{entry_date}.md"
|
||||
path.write_text(data.content)
|
||||
append_audit({"action": "journal_saved", "date": entry_date})
|
||||
return {"status": "saved", "date": entry_date}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@app.get("/api/journal/search")
|
||||
def search_journal(q: str = Query("")):
|
||||
try:
|
||||
ensure_dir(JOURNAL_DIR)
|
||||
if not q:
|
||||
return {"results": []}
|
||||
results = []
|
||||
for f in JOURNAL_DIR.glob("*.md"):
|
||||
content = f.read_text()
|
||||
if q.lower() in content.lower():
|
||||
results.append({"date": f.stem, "preview": content[:200]})
|
||||
return {"results": results, "query": q}
|
||||
except Exception as e:
|
||||
return {"results": [], "error": str(e)}
|
||||
|
||||
# ─── Routes: Agent Health (3 endpoints) ──────────────────────────
|
||||
|
||||
@app.get("/api/agents/health")
|
||||
def get_agent_health():
|
||||
try:
|
||||
agents = []
|
||||
for name in ["opencode", "hermes", "gemini"]:
|
||||
info = check_agent(name)
|
||||
info["uptime"] = 0
|
||||
info["success_rate"] = 100
|
||||
info["last_seen"] = get_timestamp()
|
||||
agents.append(info)
|
||||
return {"agents": agents, "updated": get_timestamp()}
|
||||
except Exception as e:
|
||||
return {"agents": [], "error": str(e), "updated": get_timestamp()}
|
||||
|
||||
@app.get("/api/agents/{name}/stats")
|
||||
def get_agent_stats(name: str):
|
||||
try:
|
||||
if name not in ["opencode", "hermes", "gemini"]:
|
||||
raise HTTPException(400, "Invalid agent")
|
||||
info = check_agent(name)
|
||||
return {
|
||||
"name": name,
|
||||
"status": info["status"],
|
||||
"total_runs": 0,
|
||||
"successful_runs": 0,
|
||||
"failed_runs": 0,
|
||||
"avg_response_time": 0,
|
||||
"last_seen": get_timestamp(),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@app.post("/api/agents/health/refresh")
|
||||
def refresh_agent_health():
|
||||
try:
|
||||
agents = []
|
||||
for name in ["opencode", "hermes", "gemini"]:
|
||||
info = check_agent(name)
|
||||
agents.append(info)
|
||||
append_audit({"action": "agent_health_refreshed"})
|
||||
return {"agents": agents, "updated": get_timestamp()}
|
||||
except Exception as e:
|
||||
return {"agents": [], "error": str(e)}
|
||||
|
||||
# ─── Routes: Smart Router (2 endpoints) ─────────────────────────
|
||||
|
||||
ROUTER_RULES = {
|
||||
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
|
||||
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
|
||||
"gemini": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
|
||||
}
|
||||
|
||||
@app.post("/api/router/suggest")
|
||||
def router_suggest(data: RouterSuggest):
|
||||
try:
|
||||
task_lower = data.task.lower()
|
||||
scores = {}
|
||||
for agent, keywords in ROUTER_RULES.items():
|
||||
scores[agent] = sum(1 for k in keywords if k in task_lower)
|
||||
best = max(scores, key=scores.get)
|
||||
confidence = "high" if scores[best] >= 2 else "medium" if scores[best] == 1 else "low"
|
||||
return {
|
||||
"suggested_agent": best,
|
||||
"confidence": confidence,
|
||||
"scores": scores,
|
||||
"task": data.task,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"suggested_agent": "opencode", "confidence": "low", "error": str(e)}
|
||||
|
||||
@app.post("/api/router/route")
|
||||
def router_route(data: RouterRoute):
|
||||
try:
|
||||
agent = data.agent.lower()
|
||||
if agent not in ["opencode", "hermes", "gemini"]:
|
||||
return {"status": "error", "message": f"Invalid agent: {agent}"}
|
||||
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
|
||||
return {
|
||||
"status": "routed",
|
||||
"agent": agent,
|
||||
"task": data.task,
|
||||
"message": f"Task routed to {agent}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
# ─── Routes: Learning Analytics (2 endpoints) ───────────────────
|
||||
|
||||
@app.get("/api/analytics/skills")
|
||||
def get_skill_analytics():
|
||||
try:
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
analytics = []
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
eval_path = d / "eval.json"
|
||||
score_path = d / "score-history.json"
|
||||
scores = json.loads(score_path.read_text()) if score_path.exists() else []
|
||||
eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {}
|
||||
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
|
||||
analytics.append({
|
||||
"name": d.name,
|
||||
"total_runs": len(scores),
|
||||
"avg_score": round(avg_score, 1),
|
||||
"last_score": scores[-1].get("score", 0) if scores else 0,
|
||||
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
|
||||
})
|
||||
return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)}
|
||||
except Exception as e:
|
||||
return {"skills": [], "error": str(e)}
|
||||
|
||||
@app.get("/api/analytics/trends")
|
||||
def get_trend_analytics():
|
||||
try:
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
trends = []
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
score_path = d / "score-history.json"
|
||||
scores = json.loads(score_path.read_text()) if score_path.exists() else []
|
||||
if scores:
|
||||
trends.append({
|
||||
"name": d.name,
|
||||
"scores": [s.get("score", 0) for s in scores[-10:]],
|
||||
"labels": [s.get("date", "") for s in scores[-10:]],
|
||||
})
|
||||
return {"trends": trends}
|
||||
except Exception as e:
|
||||
return {"trends": [], "error": str(e)}
|
||||
|
||||
# ─── Routes: Session Replay (2 endpoints) ───────────────────────
|
||||
|
||||
@app.get("/api/sessions/list")
|
||||
def list_sessions():
|
||||
try:
|
||||
sessions = []
|
||||
sessions_dir = Path.home() / ".local" / "share" / "opencode"
|
||||
log_dir = sessions_dir / "log"
|
||||
if log_dir.exists():
|
||||
for f in sorted(log_dir.glob("*.log"), reverse=True)[:20]:
|
||||
sessions.append({
|
||||
"id": f.stem,
|
||||
"name": f.stem,
|
||||
"size": f.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
"source": "opencode",
|
||||
})
|
||||
hermes_sessions = Path.home() / ".hermes" / "sessions.json"
|
||||
if hermes_sessions.exists():
|
||||
sessions.append({
|
||||
"id": "hermes-sessions",
|
||||
"name": "Hermes Session Archive",
|
||||
"size": hermes_sessions.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(hermes_sessions.stat().st_mtime).isoformat(),
|
||||
"source": "hermes",
|
||||
})
|
||||
return {"sessions": sessions}
|
||||
except Exception as e:
|
||||
return {"sessions": [], "error": str(e)}
|
||||
|
||||
@app.get("/api/sessions/{session_id}/replay")
|
||||
def get_session_replay(session_id: str):
|
||||
try:
|
||||
sessions_dir = Path.home() / ".local" / "share" / "opencode"
|
||||
log_file = sessions_dir / "log" / f"{session_id}.log"
|
||||
if log_file.exists():
|
||||
content = log_file.read_text()
|
||||
lines = content.split("\n")
|
||||
messages = []
|
||||
for line in lines:
|
||||
if "user:" in line.lower() or "assistant:" in line.lower():
|
||||
messages.append(line)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"lines": len(lines),
|
||||
"messages": messages[:100],
|
||||
"content": content[:5000],
|
||||
}
|
||||
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
|
||||
except Exception as e:
|
||||
return {"session_id": session_id, "messages": [], "error": str(e)}
|
||||
|
||||
# ─── Routes: Dashboard Static Files ──────────────────────────────
|
||||
|
||||
dashboard_dir = BASE_DIR / "dashboard"
|
||||
|
|
|
|||
Loading…
Reference in New Issue