diff --git a/docker-compose.yml b/docker-compose.yml index b6c27d8..01266eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,11 @@ services: environment: - PORT=8395 - DB_PATH=/app/data/tasks.db - - WIKI_DIR=/home/bear/.openclaw/workspace/wiki + - WIKI_DIR=/app/wiki + - AGENTS_DIR=/app/agents + - OPENCLAW_CONFIG=/app/config/openclaw.json volumes: - ./data:/app/data - - /home/bear/.openclaw/workspace/wiki:/home/bear/.openclaw/workspace/wiki - networks: - - proxy-net - -networks: - proxy-net: - external: true + - /home/bear/.openclaw/workspace/wiki:/app/wiki + - /home/bear/.openclaw/agents:/app/agents:ro + - /home/bear/.openclaw/openclaw.json:/app/config/openclaw.json:ro diff --git a/public/app.js b/public/app.js index 5c9e9eb..a9dbee0 100644 --- a/public/app.js +++ b/public/app.js @@ -1,141 +1,272 @@ -const STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done']; +// Navigation +const navLinks = document.querySelectorAll('.nav-link'); +const pages = document.querySelectorAll('.page'); -const board = document.getElementById('board'); -const template = document.getElementById('task-template'); -const form = document.getElementById('task-form'); - -let tasks = []; - -function renderBoard() { - board.innerHTML = ''; - - for (const status of STATUSES) { - const column = document.createElement('section'); - column.className = 'column'; - - const heading = document.createElement('h2'); - heading.textContent = `${status} (${tasks.filter((t) => t.status === status).length})`; - - const cards = document.createElement('div'); - cards.className = 'cards'; - - tasks - .filter((task) => task.status === status) - .forEach((task) => cards.appendChild(renderCard(task))); - - column.appendChild(heading); - column.appendChild(cards); - board.appendChild(column); - } -} - -function renderCard(task) { - const node = template.content.firstElementChild.cloneNode(true); - - node.querySelector('.card-title').textContent = task.title; - node.querySelector('.card-desc').textContent = task.description || 'No description'; - node.querySelector('.assignee').textContent = `Assignee: ${task.assignee || 'Unassigned'}`; - node.querySelector('.tags').textContent = `Tags: ${(task.tags || []).join(', ') || 'None'}`; - - const badge = node.querySelector('.priority'); - badge.textContent = task.priority; - badge.classList.add((task.priority || '').toLowerCase()); - - const statusSelect = node.querySelector('.status-select'); - statusSelect.value = task.status; - statusSelect.addEventListener('change', async () => { - await updateTask(task.id, { status: statusSelect.value }); +navLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetPage = link.dataset.page; + + // Update active nav link + navLinks.forEach(l => l.classList.remove('active')); + link.classList.add('active'); + + // Show target page + pages.forEach(page => { + page.classList.remove('active'); + if (page.id === `page-${targetPage}`) { + page.classList.add('active'); + } + }); + + // Load page data + if (targetPage === 'wiki') loadWiki(); + if (targetPage === 'agents') loadAgents(); + if (targetPage === 'usage') loadUsage(); }); +}); - return node; -} +// Task Dashboard +const COLUMNS = { + 'Backlog': { title: '📋 Backlog', tasks: [] }, + 'Todo': { title: '📝 Todo', tasks: [] }, + 'In Progress': { title: '🔄 In Progress', tasks: [] }, + 'Review': { title: '👀 Review', tasks: [] }, + 'Done': { title: '✅ Done', tasks: [] } +}; async function loadTasks() { const res = await fetch('/api/tasks'); - tasks = await res.json(); + const tasks = await res.json(); + + // Reset columns + Object.keys(COLUMNS).forEach(status => { + COLUMNS[status].tasks = []; + }); + + // Group tasks by status + tasks.forEach(task => { + if (COLUMNS[task.status]) { + COLUMNS[task.status].tasks.push(task); + } + }); + renderBoard(); } -async function createTask(payload) { - const res = await fetch('/api/tasks', { +function renderBoard() { + const board = document.getElementById('board'); + board.innerHTML = ''; + + Object.entries(COLUMNS).forEach(([status, column]) => { + const columnEl = document.createElement('div'); + columnEl.className = 'column'; + + columnEl.innerHTML = ` +
+

${column.title}

+ ${column.tasks.length} +
+
+ `; + + const cardsEl = columnEl.querySelector('.cards'); + + column.tasks.forEach(task => { + const cardEl = document.createElement('div'); + cardEl.className = 'card'; + cardEl.innerHTML = ` +
+

${escapeHtml(task.title)}

+ ${task.priority} +
+

${escapeHtml(task.description || '')}

+

${task.assignee || 'Unassigned'}

+

${task.tags.map(t => `${escapeHtml(t)}`).join(' ')}

+ + `; + + // Checkbox handler + const checkbox = cardEl.querySelector('.card-check'); + checkbox.addEventListener('change', async () => { + await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'Done' }) + }); + loadTasks(); + }); + + cardsEl.appendChild(cardEl); + }); + + board.appendChild(columnEl); + }); +} + +// Task form +document.getElementById('task-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = new FormData(e.target); + const task = { + title: formData.get('title'), + description: formData.get('description'), + assignee: formData.get('assignee'), + priority: formData.get('priority'), + status: formData.get('status'), + tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t) + }; + + await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify(task) }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'failed_to_create_task'); - } - - return res.json(); -} - -async function updateTask(id, payload) { - const res = await fetch(`/api/tasks/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'failed_to_update_task'); - } - - return res.json(); -} - -form.addEventListener('submit', async (e) => { - e.preventDefault(); - - const payload = { - title: form.title.value, - description: form.description.value, - assignee: form.assignee.value, - priority: form.priority.value, - status: form.status.value, - tags: form.tags.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - }; - - try { - await createTask(payload); - form.reset(); - form.priority.value = 'Medium'; - form.status.value = 'Backlog'; - } catch (err) { - alert(err.message); - } + + e.target.reset(); + loadTasks(); }); -function upsertTask(task) { - const idx = tasks.findIndex((t) => t.id === task.id); - if (idx === -1) { - tasks.unshift(task); - } else { - tasks[idx] = task; +// Wiki +async function loadWiki() { + const res = await fetch('/api/wiki'); + const pages = await res.json(); + + const wikiList = document.getElementById('wiki-list'); + wikiList.innerHTML = ''; + + pages.forEach(page => { + const itemEl = document.createElement('div'); + itemEl.className = 'wiki-item'; + itemEl.innerHTML = ` +

${escapeHtml(page.filename.replace('.md', ''))}

+

${new Date(page.created).toLocaleDateString()}

+ `; + + itemEl.addEventListener('click', async () => { + // Mark active + wikiList.querySelectorAll('.wiki-item').forEach(i => i.classList.remove('active')); + itemEl.classList.add('active'); + + // Load content + const contentRes = await fetch(`/api/wiki/${page.filename}`); + const contentData = await contentRes.json(); + + const wikiContent = document.getElementById('wiki-content'); + wikiContent.innerHTML = `
${escapeHtml(contentData.content)}
`; + }); + + wikiList.appendChild(itemEl); + }); +} + +// Agents +async function loadAgents() { + const res = await fetch('/api/agents'); + const agents = await res.json(); + + const grid = document.getElementById('agents-grid'); + grid.innerHTML = ''; + + agents.forEach(agent => { + const cardEl = document.createElement('div'); + cardEl.className = 'agent-card'; + + cardEl.innerHTML = ` +
+

${escapeHtml(agent.name)}

+ ${agent.status} +
+
+
+

📋 Current Task

+

${agent.currentTask || 'No active task'}

+
+
+

🛠️ Tools

+
+ ${agent.tools.map(tool => `${escapeHtml(tool)}`).join('')} +
+
+
+

📄 Workspace Files

+
+ ${agent.files.map(file => `${escapeHtml(file)}`).join('')} +
+
+
+ `; + + grid.appendChild(cardEl); + }); +} + +// Usage +async function loadUsage() { + const res = await fetch('/api/usage'); + const usage = await res.json(); + + const container = document.getElementById('usage-container'); + container.innerHTML = ''; + + if (usage.providers.length === 0) { + container.innerHTML = '

No provider data available

'; + return; } - renderBoard(); + + usage.providers.forEach(provider => { + const cardEl = document.createElement('div'); + cardEl.className = 'usage-card'; + + cardEl.innerHTML = ` +

${escapeHtml(provider.name)}

+
+ ${provider.models.map(model => ` +
+
${escapeHtml(model.name)}
+
Type: ${model.type} | Context: ${model.contextWindow}
+
+ `).join('')} +
+
+
Quota & Limits
+
+ Requests + ${provider.quota.requests} / ${provider.quota.limit} +
+
+ Tokens + ${provider.quota.tokens} +
+
+
+
+
+ `; + + container.appendChild(cardEl); + }); } -function connectWebSocket() { - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${proto}//${location.host}`); - - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.type === 'task_created' || msg.type === 'task_updated') { - upsertTask(msg.payload); - } - }; - - ws.onclose = () => { - setTimeout(connectWebSocket, 1200); - }; +// Utility functions +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } +// Initialize loadTasks(); -connectWebSocket(); + +// WebSocket for real-time updates +const ws = new WebSocket(`ws://${window.location.host}`); +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'task_created' || data.type === 'task_updated') { + loadTasks(); + } +}; diff --git a/public/index.html b/public/index.html index 3d15d19..54699ff 100644 --- a/public/index.html +++ b/public/index.html @@ -3,40 +3,87 @@ - OpenClaw Fleet Taskboard + OpenClaw Agent Fleet Dashboard -
-

OpenClaw Agent Fleet Dashboard

-

Real-time task coordination board

-
+ -
-

Create Task

-
- - - - - - - -
-
+ +
+
+

Task Dashboard

+

Real-time task coordination board

+
-
+
+

Create Task

+
+ + + + + + + +
+
+ +
+
+ + +
+
+

📚 Wiki

+

Task documentation and implementation details

+
+
+
+
+

Select a wiki page to view documentation

+
+
+
+ + +
+
+

🤖 Agents

+

Fleet agent workspace and configuration

+
+
+
+ + +
+
+

📊 Usage & Quotas

+

Provider models, quotas, and limits

+
+
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css index f495498..931ccaa 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,182 +1,531 @@ -:root { - --bg: #0e1117; - --panel: #161b22; - --muted: #98a6b3; - --text: #e6edf3; - --border: #2d333b; - --accent: #2f81f7; - --critical: #f85149; - --high: #db6d28; - --medium: #d29922; - --low: #238636; -} - * { + margin: 0; + padding: 0; box-sizing: border-box; } +:root { + --bg-primary: #0f1419; + --bg-secondary: #1a1f2e; + --bg-card: #242b3d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --accent: #f78166; + --border: #30363d; + --priority-high: #f85149; + --priority-medium: #d29922; + --priority-low: #3fb950; + --priority-critical: #ff6b6b; +} + body { - margin: 0; - font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - background: radial-gradient(circle at top, #1c2431, var(--bg) 55%); - color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; } -.topbar { - padding: 1.2rem; +/* Navigation */ +.navbar { + background: var(--bg-secondary); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; border-bottom: 1px solid var(--border); - background: rgba(22, 27, 34, 0.9); + position: sticky; + top: 0; + z-index: 1000; } -.topbar h1 { - margin: 0; - font-size: 1.4rem; +.nav-brand h1 { + font-size: 1.5rem; + color: var(--text-primary); +} + +.nav-links { + display: flex; + gap: 1rem; +} + +.nav-link { + color: var(--text-secondary); + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: 6px; + transition: all 0.2s; +} + +.nav-link:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.nav-link.active { + background: var(--accent); + color: white; +} + +/* Pages */ +.page { + display: none; +} + +.page.active { + display: block; +} + +/* Dashboard */ +.topbar { + padding: 1.5rem 2rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.topbar h2 { + font-size: 1.5rem; + margin-bottom: 0.5rem; } .topbar p { - margin: 0.25rem 0 0; - color: var(--muted); + color: var(--text-secondary); } .composer { - padding: 1rem 1.2rem; -} - -.composer h2 { - margin: 0 0 0.7rem; - font-size: 1rem; -} - -#task-form { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 0.6rem; -} - -input, -select, -textarea, -button { - border: 1px solid var(--border); - background: var(--panel); - color: var(--text); + padding: 1.5rem 2rem; + background: var(--bg-secondary); + margin: 1rem; border-radius: 8px; - padding: 0.55rem 0.7rem; + border: 1px solid var(--border); } -textarea { - grid-column: 1 / -1; - min-height: 70px; - resize: vertical; +.composer h3 { + margin-bottom: 1rem; } -button { - cursor: pointer; - background: linear-gradient(90deg, #1f6feb, var(--accent)); - border: none; - font-weight: 600; -} - -.board { +.composer form { display: grid; - grid-template-columns: repeat(5, minmax(220px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; - padding: 0 1.2rem 1.2rem; +} + +.composer input, +.composer select, +.composer textarea { + padding: 0.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 4px; +} + +.composer textarea { + grid-column: 1 / -1; + min-height: 80px; +} + +.composer button { + padding: 0.5rem 1.5rem; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; +} + +.composer button:hover { + opacity: 0.9; +} + +/* Board */ +.board { + display: flex; + gap: 1rem; + padding: 1rem; overflow-x: auto; + min-height: calc(100vh - 400px); } .column { - background: rgba(22, 27, 34, 0.9); - border: 1px solid var(--border); - border-radius: 10px; - min-height: 220px; - padding: 0.7rem; + flex: 0 0 280px; + background: var(--bg-secondary); + border-radius: 8px; + display: flex; + flex-direction: column; + max-height: calc(100vh - 250px); } -.column h2 { - font-size: 0.95rem; - margin: 0 0 0.6rem; - color: var(--muted); +.column-header { + padding: 1rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.column-header h3 { + font-size: 0.9rem; + text-transform: uppercase; +} + +.column-count { + background: var(--bg-card); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; } .cards { - display: flex; - flex-direction: column; - gap: 0.6rem; + flex: 1; + overflow-y: auto; + padding: 0.5rem; } .card { - background: #202632; - border: 1px solid #334055; - border-radius: 8px; - padding: 0.6rem; + background: var(--bg-card); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.5rem; + cursor: pointer; + border: 1px solid transparent; +} + +.card:hover { + border-color: var(--accent); } .card-head { display: flex; justify-content: space-between; - gap: 0.4rem; - align-items: flex-start; + align-items: center; + margin-bottom: 0.5rem; } .card-title { - margin: 0; + font-weight: 500; font-size: 0.95rem; } +.badge { + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 600; +} + +.priority-High { + background: var(--priority-high); + color: white; +} + +.priority-Medium { + background: var(--priority-medium); + color: white; +} + +.priority-Low { + background: var(--priority-low); + color: white; +} + +.priority-Critical { + background: var(--priority-critical); + color: white; +} + .card-desc { - margin: 0.5rem 0; + color: var(--text-secondary); font-size: 0.85rem; - color: #c7d3df; + margin-bottom: 0.5rem; } .meta { - margin: 0.2rem 0; - color: var(--muted); - font-size: 0.78rem; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; } -.badge { - border-radius: 999px; - font-size: 0.72rem; - padding: 0.18rem 0.45rem; - border: 1px solid transparent; - white-space: nowrap; +.assignee { + color: var(--accent); } -.priority.low { - color: var(--low); - border-color: var(--low); +.tags { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; } -.priority.medium { - color: var(--medium); - border-color: var(--medium); +.tag { + background: var(--bg-primary); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; } -.priority.high { - color: var(--high); - border-color: var(--high); +.card label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.5rem; } -.priority.critical { - color: var(--critical); - border-color: var(--critical); +/* Wiki */ +.wiki-container { + display: grid; + grid-template-columns: 300px 1fr; + gap: 1rem; + padding: 1rem; + min-height: calc(100vh - 200px); } -.status-select { - width: 100%; - margin-top: 0.2rem; +.wiki-list { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1rem; + overflow-y: auto; + max-height: calc(100vh - 220px); } -@media (max-width: 980px) { - .board { - grid-template-columns: repeat(2, minmax(240px, 1fr)); +.wiki-item { + padding: 0.75rem; + border-radius: 6px; + cursor: pointer; + margin-bottom: 0.5rem; + background: var(--bg-card); +} + +.wiki-item:hover { + background: var(--accent); +} + +.wiki-item.active { + background: var(--accent); +} + +.wiki-title { + font-size: 0.9rem; + margin-bottom: 0.25rem; +} + +.wiki-date { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.wiki-content { + background: var(--bg-secondary); + border-radius: 8px; + padding: 2rem; + overflow-y: auto; + max-height: calc(100vh - 220px); +} + +.wiki-content h1 { + margin-bottom: 1rem; +} + +.wiki-content h2 { + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.wiki-content p { + margin-bottom: 1rem; +} + +.wiki-content code { + background: var(--bg-card); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.wiki-content pre { + background: var(--bg-card); + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1rem; +} + +/* Agents */ +.agents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.agent-card { + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border); + overflow: hidden; +} + +.agent-header { + background: var(--bg-card); + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.agent-name { + font-size: 1.1rem; +} + +.agent-status { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + background: var(--priority-low); + color: white; +} + +.agent-body { + padding: 1rem; +} + +.agent-section { + margin-bottom: 1rem; +} + +.agent-section h4 { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.agent-task { + color: var(--text-primary); + font-size: 0.9rem; +} + +.agent-tools, +.agent-files { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.tool-tag, +.file-tag { + background: var(--bg-card); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* Usage */ +.usage-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.usage-card { + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border); + padding: 1.5rem; +} + +.provider-name { + font-size: 1.2rem; + margin-bottom: 1rem; + color: var(--accent); +} + +.provider-models { + margin-bottom: 1rem; +} + +.model-item { + padding: 0.5rem; + background: var(--bg-card); + border-radius: 4px; + margin-bottom: 0.5rem; +} + +.model-name { + font-weight: 500; + margin-bottom: 0.25rem; +} + +.model-meta { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.provider-quota { + padding: 1rem; + background: var(--bg-card); + border-radius: 6px; +} + +.quota-title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.quota-item { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.quota-label { + color: var(--text-secondary); +} + +.quota-value { + font-weight: 500; +} + +.quota-bar { + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; + margin-top: 0.5rem; +} + +.quota-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s; +} + +/* Responsive */ +@media (max-width: 768px) { + .navbar { + flex-direction: column; + gap: 1rem; } -} - -@media (max-width: 620px) { - .board { + + .nav-links { + width: 100%; + justify-content: space-around; + } + + .wiki-container { grid-template-columns: 1fr; } + + .board { + flex-direction: column; + } + + .column { + flex: 0 0 auto; + max-height: none; + } } diff --git a/push-to-gitea.sh b/push-to-gitea.sh new file mode 100755 index 0000000..5e2d318 --- /dev/null +++ b/push-to-gitea.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Script to push OpenClaw Agent Fleet Dashboard to Gitea + +echo "Creating Gitea repository..." +echo "" +echo "Please follow these steps:" +echo "1. Open https://gitea.tophermayor.com in your browser" +echo "2. Log in to your account" +echo "3. Click the '+' button in the top right corner" +echo "4. Select 'New Repository'" +echo "5. Enter repository details:" +echo " - Owner: topher (or your username)" +echo " - Repository Name: openclaw-taskboard" +echo " - Description: OpenClaw Agent Fleet Dashboard - Task coordination board" +echo " - Visibility: Public or Private (your choice)" +echo " - Do NOT initialize with README, .gitignore, or license" +echo "6. Click 'Create Repository'" +echo "" +echo "After creating the repository, run:" +echo "" +echo "cd /tmp/taskboard-gitea" +echo "git remote add origin https://gitea.tophermayor.com/topher/openclaw-taskboard.git" +echo "git branch -M main" +echo "git push -u origin main" +echo "" +echo "Or if you prefer SSH:" +echo "git remote add origin git@gitea.tophermayor.com:topher/openclaw-taskboard.git" +echo "git branch -M main" +echo "git push -u origin main" diff --git a/server.js b/server.js index ce2fc6f..ebdaadf 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ const PORT = process.env.PORT || 8395; const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db'); const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki'; +const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents'; +const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json'; const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done']; const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; @@ -227,6 +229,140 @@ app.patch('/api/tasks/:id', (req, res) => { } catch (wikiErr) { console.error('wiki_creation_error', wikiErr); } + +// Agents endpoint +app.get('/api/agents', (req, res) => { + try { + const agents = []; + + if (fs.existsSync(AGENTS_DIR)) { + const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => { + return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory(); + }); + + agentDirs.forEach(agentName => { + const agentPath = path.join(AGENTS_DIR, agentName); + const workspacePath = path.join(agentPath, 'workspace'); + + const agent = { + name: agentName, + status: 'active', + currentTask: null, + tools: [], + files: [], + permissions: [] + }; + + // Read workspace files + if (fs.existsSync(workspacePath)) { + const files = fs.readdirSync(workspacePath); + agent.files = files.filter(f => f.endsWith('.md')); + + // Read MEMORY.md for tools + const memoryPath = path.join(workspacePath, 'MEMORY.md'); + if (fs.existsSync(memoryPath)) { + const memory = fs.readFileSync(memoryPath, 'utf8'); + // Extract tools from memory + const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i); + if (toolMatches) { + agent.tools = toolMatches[1].split('\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()); + } + } + + // Read HEARTBEAT.md for current task + const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md'); + if (fs.existsSync(heartbeatPath)) { + const heartbeat = fs.readFileSync(heartbeatPath, 'utf8'); + const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i); + if (taskMatch) { + agent.currentTask = taskMatch[1].trim(); + } + } + } + + agents.push(agent); + }); + } + + res.json(agents); + } catch (err) { + console.error('Error reading agents:', err); + res.status(500).json({ error: 'failed_to_fetch_agents' }); + } +}); + +// Usage endpoint +app.get('/api/usage', (req, res) => { + try { + const usage = { + providers: [], + lastUpdated: new Date().toISOString() + }; + + // Read OpenClaw config + if (fs.existsSync(OPENCLAW_CONFIG)) { + const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8')); + + // Extract provider information + if (config.models) { + const providerMap = {}; + + Object.entries(config.models).forEach(([modelName, modelConfig]) => { + const provider = modelConfig.provider || 'unknown'; + + if (!providerMap[provider]) { + providerMap[provider] = { + name: provider, + models: [], + quota: { + requests: 0, + tokens: 0, + limit: 'unlimited' + } + }; + } + + providerMap[provider].models.push({ + name: modelName, + type: modelConfig.type || 'chat', + contextWindow: modelConfig.context_window || 'unknown' + }); + }); + + usage.providers = Object.values(providerMap); + } + } + + res.json(usage); + } catch (err) { + console.error('Error reading usage:', err); + res.status(500).json({ error: 'failed_to_fetch_usage' }); + } +}); + +// Heartbeat endpoint for agents +app.get('/api/heartbeat/:agent', (req, res) => { + const agent = req.params.agent; + + db.all( + 'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC', + [agent, 'Todo', 'In Progress', 'Review'], + (err, rows) => { + if (err) { + return res.status(500).json({ error: 'failed_to_fetch_tasks' }); + } + + const tasks = rows.map(normalizeTask); + res.json({ + agent, + pending_tasks: tasks.length, + tasks + }); + } + ); +}); } broadcast('task_updated', task); @@ -237,6 +373,135 @@ app.patch('/api/tasks/:id', (req, res) => { }); }); + +// Agents endpoint +app.get('/api/agents', (req, res) => { + try { + const agents = []; + + if (fs.existsSync(AGENTS_DIR)) { + const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => { + return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory(); + }); + + agentDirs.forEach(agentName => { + const agentPath = path.join(AGENTS_DIR, agentName); + const workspacePath = path.join(agentPath, 'workspace'); + + const agent = { + name: agentName, + status: 'active', + currentTask: null, + tools: [], + files: [], + permissions: [] + }; + + if (fs.existsSync(workspacePath)) { + const files = fs.readdirSync(workspacePath); + agent.files = files.filter(f => f.endsWith('.md')); + + const memoryPath = path.join(workspacePath, 'MEMORY.md'); + if (fs.existsSync(memoryPath)) { + const memory = fs.readFileSync(memoryPath, 'utf8'); + const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i); + if (toolMatches) { + agent.tools = toolMatches[1].split('\\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^-\\s*/, '').trim()); + } + } + + const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md'); + if (fs.existsSync(heartbeatPath)) { + const heartbeat = fs.readFileSync(heartbeatPath, 'utf8'); + const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i); + if (taskMatch) { + agent.currentTask = taskMatch[1].trim(); + } + } + } + + agents.push(agent); + }); + } + + res.json(agents); + } catch (err) { + console.error('Error reading agents:', err); + res.status(500).json({ error: 'failed_to_fetch_agents' }); + } +}); + +// Usage endpoint +app.get('/api/usage', (req, res) => { + try { + const usage = { + providers: [], + lastUpdated: new Date().toISOString() + }; + + if (fs.existsSync(OPENCLAW_CONFIG)) { + const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8')); + + if (config.models) { + const providerMap = {}; + + Object.entries(config.models).forEach(([modelName, modelConfig]) => { + const provider = modelConfig.provider || 'unknown'; + + if (!providerMap[provider]) { + providerMap[provider] = { + name: provider, + models: [], + quota: { + requests: 0, + tokens: 0, + limit: 'unlimited' + } + }; + } + + providerMap[provider].models.push({ + name: modelName, + type: modelConfig.type || 'chat', + contextWindow: modelConfig.context_window || 'unknown' + }); + }); + + usage.providers = Object.values(providerMap); + } + } + + res.json(usage); + } catch (err) { + console.error('Error reading usage:', err); + res.status(500).json({ error: 'failed_to_fetch_usage' }); + } +}); + +// Heartbeat endpoint for agents +app.get('/api/heartbeat/:agent', (req, res) => { + const agent = req.params.agent; + + db.all( + 'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC', + [agent, 'Todo', 'In Progress', 'Review'], + (err, rows) => { + if (err) { + return res.status(500).json({ error: 'failed_to_fetch_tasks' }); + } + + const tasks = rows.map(normalizeTask); + res.json({ + agent, + pending_tasks: tasks.length, + tasks + }); + } + ); +}); + wss.on('connection', (socket) => { socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } })); });