1140 lines
46 KiB
JavaScript
1140 lines
46 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
|
AETHEEL MISSION CONTROL — CLIENT APP
|
|
═══════════════════════════════════════════════════════════ */
|
|
|
|
const API = ''; // Same origin
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const $$ = (sel) => document.querySelectorAll(sel);
|
|
|
|
// ── State ────────────────────────────────────────────────
|
|
let currentPage = 'command-center';
|
|
let sseSource = null;
|
|
let uptimeInterval = null;
|
|
let statsData = {};
|
|
let activityData = [];
|
|
|
|
// ── Router ───────────────────────────────────────────────
|
|
function getPage() {
|
|
const hash = window.location.hash.slice(1) || '/';
|
|
const routes = {
|
|
'/': 'command-center',
|
|
'/activity': 'activity',
|
|
'/sessions': 'sessions',
|
|
'/brain': 'brain',
|
|
'/productivity': 'productivity',
|
|
'/content': 'content',
|
|
'/tasks': 'tasks',
|
|
'/connections': 'connections',
|
|
'/settings': 'settings',
|
|
};
|
|
return routes[hash] || 'command-center';
|
|
}
|
|
|
|
function navigateTo(page) {
|
|
currentPage = page;
|
|
$$('.nav-item').forEach(item => {
|
|
item.classList.toggle('active', item.dataset.page === page);
|
|
});
|
|
renderPage();
|
|
}
|
|
|
|
window.addEventListener('hashchange', () => navigateTo(getPage()));
|
|
|
|
// ── Init ─────────────────────────────────────────────────
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
navigateTo(getPage());
|
|
startSSE();
|
|
startUptimePolling();
|
|
});
|
|
|
|
// ── SSE (Real-time events) ───────────────────────────────
|
|
function startSSE() {
|
|
if (sseSource) sseSource.close();
|
|
sseSource = new EventSource(`${API}/events`);
|
|
|
|
sseSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'connected') { updateStatus(true); return; }
|
|
activityData.unshift(data);
|
|
if (activityData.length > 200) activityData = activityData.slice(0, 200);
|
|
|
|
if (currentPage === 'command-center' || currentPage === 'activity') {
|
|
const feedEl = $('#activity-feed');
|
|
if (feedEl) {
|
|
feedEl.insertAdjacentHTML('afterbegin', renderActivityItem(data));
|
|
while (feedEl.children.length > 100) feedEl.removeChild(feedEl.lastChild);
|
|
}
|
|
}
|
|
if (currentPage === 'command-center') fetchStats();
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
sseSource.onerror = () => {
|
|
updateStatus(false);
|
|
setTimeout(() => { if (sseSource?.readyState === EventSource.CLOSED) startSSE(); }, 5000);
|
|
};
|
|
}
|
|
|
|
function updateStatus(online) {
|
|
const dot = $('.status-dot');
|
|
const text = $('.status-text');
|
|
if (dot) dot.classList.toggle('offline', !online);
|
|
if (text) text.textContent = online ? 'Agent Online' : 'Disconnected';
|
|
}
|
|
|
|
// ── Uptime Polling ───────────────────────────────────────
|
|
function startUptimePolling() {
|
|
fetchStats();
|
|
if (uptimeInterval) clearInterval(uptimeInterval);
|
|
uptimeInterval = setInterval(fetchStats, 10000);
|
|
}
|
|
|
|
async function fetchStats() {
|
|
try {
|
|
const res = await fetch(`${API}/api/stats`);
|
|
statsData = await res.json();
|
|
const uptimeEl = $('#xp-uptime');
|
|
const fillEl = $('#xp-fill');
|
|
if (uptimeEl) uptimeEl.textContent = statsData.uptimeFormatted;
|
|
const hours = statsData.uptimeMs / 3600000;
|
|
const pct = Math.min(100, (hours / 24) * 100);
|
|
if (fillEl) fillEl.style.width = pct + '%';
|
|
updateStatCards();
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function updateStatCards() {
|
|
const els = {
|
|
'stat-messages': statsData.messagesHandled,
|
|
'stat-heartbeats': statsData.heartbeats,
|
|
'stat-cron': statsData.cronRuns,
|
|
'stat-uptime': statsData.uptimeFormatted,
|
|
};
|
|
for (const [id, val] of Object.entries(els)) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = val ?? 0;
|
|
}
|
|
}
|
|
|
|
// ── API Helpers ──────────────────────────────────────────
|
|
async function apiFetch(path) {
|
|
const res = await fetch(`${API}${path}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiPost(path, data) {
|
|
const res = await fetch(`${API}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDelete(path) {
|
|
const res = await fetch(`${API}${path}`, { method: 'DELETE' });
|
|
return res.json();
|
|
}
|
|
|
|
// ── Page Renderer ────────────────────────────────────────
|
|
async function renderPage() {
|
|
const container = $('#page-container');
|
|
container.innerHTML = '<div class="empty-state"><div class="empty-icon">⏳</div></div>';
|
|
|
|
switch (currentPage) {
|
|
case 'command-center': return renderCommandCenter(container);
|
|
case 'activity': return renderActivity(container);
|
|
case 'sessions': return renderSessions(container);
|
|
case 'brain': return renderBrain(container);
|
|
case 'productivity': return renderProductivity(container);
|
|
case 'content': return renderContentIntel(container);
|
|
case 'tasks': return renderTasks(container);
|
|
case 'connections': return renderConnections(container);
|
|
case 'settings': return renderSettings(container);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// COMMAND CENTER
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderCommandCenter(el) {
|
|
const [stats, activity, config] = await Promise.all([
|
|
apiFetch('/api/stats'),
|
|
apiFetch('/api/activity?count=30'),
|
|
apiFetch('/api/config'),
|
|
]);
|
|
statsData = stats;
|
|
activityData = activity;
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Command Center</h1>
|
|
<p class="page-subtitle">Real-time overview of your agent</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card orange">
|
|
<div class="stat-label">Messages Handled</div>
|
|
<div class="stat-value" id="stat-messages">${stats.messagesHandled}</div>
|
|
<span class="stat-badge orange">+${stats.messagesHandled} total</span>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Heartbeats</div>
|
|
<div class="stat-value" id="stat-heartbeats">${stats.heartbeats}</div>
|
|
<span class="stat-badge green">pulsing</span>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="stat-label">Cron Runs</div>
|
|
<div class="stat-value" id="stat-cron">${stats.cronRuns}</div>
|
|
<span class="stat-badge blue">scheduled</span>
|
|
</div>
|
|
<div class="stat-card blue">
|
|
<div class="stat-label">Agent Uptime</div>
|
|
<div class="stat-value" id="stat-uptime">${stats.uptimeFormatted}</div>
|
|
<span class="stat-badge green">online</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-grid">
|
|
<div class="section-card">
|
|
<div class="section-header">
|
|
<span class="section-title">Live Activity Feed</span>
|
|
<span class="section-badge">${activity.length} recent</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="activity-list" id="activity-feed">
|
|
${activity.map(renderActivityItem).join('')}
|
|
${activity.length === 0 ? '<div class="empty-state"><div class="empty-icon">📡</div><div class="empty-text">Waiting for events…</div></div>' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card">
|
|
<div class="section-header">
|
|
<span class="section-title">Agent Configuration</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="config-table">
|
|
${renderConfigRow('Backend', config.agentBackend)}
|
|
${renderConfigRow('CLI Path', config.backendCliPath)}
|
|
${renderConfigRow('Model', config.backendModel)}
|
|
${renderConfigRow('Max Turns', config.backendMaxTurns)}
|
|
${renderConfigRow('Timeout', (config.queryTimeoutMs / 1000) + 's')}
|
|
${renderConfigRow('Max Concurrent', config.maxConcurrentQueries)}
|
|
${renderConfigRow('Queue Depth', config.maxQueueDepth)}
|
|
${renderConfigRow('Permission Mode', config.permissionMode)}
|
|
${renderConfigRow('Output Channel', config.outputChannelId)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// ACTIVITY
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderActivity(el) {
|
|
const activity = await apiFetch('/api/activity?count=100');
|
|
activityData = activity;
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Activity</h1>
|
|
<p class="page-subtitle">Full event history — updates in real time</p>
|
|
</div>
|
|
|
|
<div class="section-card" style="animation-delay:0.1s; opacity:0;">
|
|
<div class="section-header">
|
|
<span class="section-title">Event Log</span>
|
|
<span class="section-badge">${activity.length} events</span>
|
|
</div>
|
|
<div class="section-body" style="max-height: 70vh;">
|
|
<div class="activity-list" id="activity-feed">
|
|
${activity.map(renderActivityItem).join('')}
|
|
${activity.length === 0 ? '<div class="empty-state"><div class="empty-icon">📡</div><div class="empty-text">No events yet — activity will appear here as the agent processes events</div></div>' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// SESSIONS
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderSessions(el) {
|
|
const [sessions, channels] = await Promise.all([
|
|
apiFetch('/api/sessions'),
|
|
apiFetch('/api/messages'),
|
|
]);
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Sessions</h1>
|
|
<p class="page-subtitle">Active Discord channel sessions</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card blue">
|
|
<div class="stat-label">Active Sessions</div>
|
|
<div class="stat-value">${sessions.length}</div>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Channels with History</div>
|
|
<div class="stat-value">${channels.length}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Session Bindings</span>
|
|
</div>
|
|
<div class="section-body">
|
|
${sessions.length > 0 ? `
|
|
<div class="session-list">
|
|
${sessions.map(s => `
|
|
<div class="session-item">
|
|
<div><div class="session-channel">#${s.channelId}</div></div>
|
|
<div class="session-id">${s.sessionId.slice(0, 16)}…</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : '<div class="empty-state"><div class="empty-icon">💬</div><div class="empty-text">No active sessions</div></div>'}
|
|
</div>
|
|
</div>
|
|
|
|
${channels.length > 0 ? `
|
|
<div class="section-card fade-in" style="margin-top: 20px;">
|
|
<div class="section-header">
|
|
<span class="section-title">Message History Channels</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="session-list">
|
|
${channels.map(c => `
|
|
<div class="session-item" style="cursor:pointer" onclick="viewChannelMessages('${c}')">
|
|
<div class="session-channel">#${c}</div>
|
|
<span style="font-size:12px; color: var(--text-muted);">Click to view →</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div id="message-viewer" style="margin-top: 20px;"></div>
|
|
`;
|
|
}
|
|
|
|
async function viewChannelMessages(channelId) {
|
|
const messages = await apiFetch(`/api/messages?channelId=${channelId}`);
|
|
const viewer = $('#message-viewer');
|
|
if (!viewer) return;
|
|
viewer.innerHTML = `
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Messages in #${channelId}</span>
|
|
<span class="section-badge">${messages.length}</span>
|
|
</div>
|
|
<div class="section-body" style="max-height: 50vh;">
|
|
<div class="activity-list">
|
|
${messages.map(m => `
|
|
<div class="activity-item">
|
|
<div class="activity-icon ${m.direction === 'inbound' ? 'message' : 'hook'}">
|
|
${m.direction === 'inbound' ? '↓' : '↑'}
|
|
</div>
|
|
<div class="activity-body">
|
|
<div class="activity-title">${escapeHtml(m.sender)}</div>
|
|
<div class="activity-detail">${escapeHtml(m.content.slice(0, 200))}</div>
|
|
</div>
|
|
<div class="activity-time">${formatTime(m.timestamp)}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
window.viewChannelMessages = viewChannelMessages;
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BRAIN (Second Brain)
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderBrain(el) {
|
|
const [facts, memory, persona, skills] = await Promise.all([
|
|
apiFetch('/api/brain'),
|
|
apiFetch('/api/memory'),
|
|
apiFetch('/api/persona'),
|
|
apiFetch('/api/skills'),
|
|
]);
|
|
|
|
const categories = [...new Set(facts.map(f => f.category))];
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">🧠 Second Brain</h1>
|
|
<p class="page-subtitle">Your agent's knowledge base — stored facts, memory files, and skills</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card orange">
|
|
<div class="stat-label">Stored Facts</div>
|
|
<div class="stat-value">${facts.length}</div>
|
|
<span class="stat-badge orange">indexed</span>
|
|
</div>
|
|
<div class="stat-card blue">
|
|
<div class="stat-label">Categories</div>
|
|
<div class="stat-value">${categories.length}</div>
|
|
<span class="stat-badge blue">organized</span>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Skills</div>
|
|
<div class="stat-value">${skills.length}</div>
|
|
<span class="stat-badge green">loaded</span>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="stat-label">Memory Lines</div>
|
|
<div class="stat-value">${(memory.content || '').split('\n').filter(l => l.trim()).length}</div>
|
|
<span class="stat-badge purple">stored</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="brain-tabs">
|
|
<button class="brain-tab active" onclick="switchBrainTab(this, 'facts')">📌 Facts</button>
|
|
<button class="brain-tab" onclick="switchBrainTab(this, 'memory')">📝 Memory</button>
|
|
<button class="brain-tab" onclick="switchBrainTab(this, 'persona')">🎭 Persona</button>
|
|
<button class="brain-tab" onclick="switchBrainTab(this, 'skills')">⚡ Skills</button>
|
|
</div>
|
|
|
|
<!-- FACTS TAB -->
|
|
<div id="brain-facts">
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Add Knowledge</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="brain-type-tabs">
|
|
<button class="type-tab active" data-type="note" onclick="selectBrainType(this)">📝 Quick Note</button>
|
|
<button class="type-tab" data-type="url" onclick="selectBrainType(this)">🔗 URL</button>
|
|
<button class="type-tab" data-type="file" onclick="selectBrainType(this)">📁 File Ref</button>
|
|
</div>
|
|
<div class="brain-add-form">
|
|
<textarea id="brain-content-input" class="brain-input" placeholder="Type a fact, paste a URL, or describe a file reference…" rows="3"></textarea>
|
|
<div class="brain-form-row">
|
|
<input id="brain-category-input" class="brain-input-small" type="text" placeholder="Category (e.g. research, tools)" />
|
|
<input id="brain-tags-input" class="brain-input-small" type="text" placeholder="Tags (comma separated)" />
|
|
<button class="btn primary" onclick="addBrainFact()">+ Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in" style="margin-top: 16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">Search & Browse</span>
|
|
<span class="section-badge">${facts.length} facts</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="brain-search-row">
|
|
<input id="brain-search" class="brain-input-full" type="text" placeholder="Search facts…" oninput="searchBrainFacts()" />
|
|
</div>
|
|
<div id="brain-facts-list" class="brain-facts-list">
|
|
${renderBrainFacts(facts)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MEMORY TAB -->
|
|
<div id="brain-memory" style="display:none;">
|
|
<textarea class="editor-area" id="memory-editor">${escapeHtml(memory.content || '')}</textarea>
|
|
<div class="editor-actions">
|
|
<button class="btn primary" id="save-memory-btn" onclick="saveMemory()">Save Memory</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PERSONA TAB -->
|
|
<div id="brain-persona" style="display:none;">
|
|
<textarea class="editor-area" id="persona-editor">${escapeHtml(persona.content || '')}</textarea>
|
|
<div class="editor-actions">
|
|
<button class="btn primary" id="save-persona-btn" onclick="savePersona()">Save Persona</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SKILLS TAB -->
|
|
<div id="brain-skills" style="display:none;">
|
|
${skills.length > 0 ? `
|
|
<div class="skills-grid">
|
|
${skills.map(s => `
|
|
<div class="skill-card">
|
|
<div class="skill-name">${escapeHtml(s.name)}</div>
|
|
<div class="skill-preview">${escapeHtml(s.content.slice(0, 200))}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : '<div class="empty-state"><div class="empty-icon">⚡</div><div class="empty-text">No skills loaded — add SKILL.md files to config/skills/</div></div>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let selectedBrainType = 'note';
|
|
|
|
function selectBrainType(btn) {
|
|
$$('.type-tab').forEach(t => t.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
selectedBrainType = btn.dataset.type;
|
|
const input = $('#brain-content-input');
|
|
const placeholders = {
|
|
note: 'Type a fact, paste a URL, or describe a file reference…',
|
|
url: 'Paste a URL (e.g. https://example.com/article)',
|
|
file: 'File path or reference (e.g. docs/api-spec.md)',
|
|
};
|
|
input.placeholder = placeholders[selectedBrainType] || placeholders.note;
|
|
}
|
|
window.selectBrainType = selectBrainType;
|
|
|
|
async function addBrainFact() {
|
|
const content = $('#brain-content-input')?.value?.trim();
|
|
if (!content) return;
|
|
const category = $('#brain-category-input')?.value?.trim() || 'general';
|
|
const tagsStr = $('#brain-tags-input')?.value?.trim() || '';
|
|
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
|
|
await apiPost('/api/brain', {
|
|
content,
|
|
type: selectedBrainType,
|
|
category,
|
|
tags,
|
|
});
|
|
|
|
// Reset inputs
|
|
$('#brain-content-input').value = '';
|
|
$('#brain-category-input').value = '';
|
|
$('#brain-tags-input').value = '';
|
|
|
|
// Refresh facts list
|
|
const facts = await apiFetch('/api/brain');
|
|
const listEl = $('#brain-facts-list');
|
|
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
|
|
}
|
|
window.addBrainFact = addBrainFact;
|
|
|
|
async function deleteBrainFact(id) {
|
|
await apiDelete(`/api/brain?id=${id}`);
|
|
const facts = await apiFetch('/api/brain');
|
|
const listEl = $('#brain-facts-list');
|
|
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
|
|
}
|
|
window.deleteBrainFact = deleteBrainFact;
|
|
|
|
async function searchBrainFacts() {
|
|
const query = $('#brain-search')?.value?.trim() || '';
|
|
const url = query ? `/api/brain?q=${encodeURIComponent(query)}` : '/api/brain';
|
|
const facts = await apiFetch(url);
|
|
const listEl = $('#brain-facts-list');
|
|
if (listEl) listEl.innerHTML = renderBrainFacts(facts);
|
|
}
|
|
window.searchBrainFacts = searchBrainFacts;
|
|
|
|
function renderBrainFacts(facts) {
|
|
if (facts.length === 0) return '<div class="empty-state"><div class="empty-icon">🧠</div><div class="empty-text">No facts stored yet — add your first knowledge item above</div></div>';
|
|
return facts.map(f => `
|
|
<div class="brain-fact-card">
|
|
<div class="brain-fact-header">
|
|
<span class="brain-fact-type ${f.type}">${f.type === 'url' ? '🔗' : f.type === 'file' ? '📁' : '📝'} ${f.type}</span>
|
|
<span class="brain-fact-category">${escapeHtml(f.category)}</span>
|
|
<button class="brain-fact-delete" onclick="deleteBrainFact('${f.id}')" title="Delete">✕</button>
|
|
</div>
|
|
<div class="brain-fact-content">${f.type === 'url' ? `<a href="${escapeHtml(f.content)}" target="_blank" rel="noopener">${escapeHtml(f.content)}</a>` : escapeHtml(f.content)}</div>
|
|
${f.tags.length > 0 ? `<div class="brain-fact-tags">${f.tags.map(t => `<span class="brain-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
|
<div class="brain-fact-time">${formatTime(f.createdAt)}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function switchBrainTab(btn, tab) {
|
|
$$('.brain-tab').forEach(t => t.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
['facts', 'memory', 'persona', 'skills'].forEach(t => {
|
|
const el = document.getElementById(`brain-${t}`);
|
|
if (el) el.style.display = t === tab ? 'block' : 'none';
|
|
});
|
|
}
|
|
window.switchBrainTab = switchBrainTab;
|
|
|
|
async function saveMemory() {
|
|
const btn = $('#save-memory-btn');
|
|
const content = $('#memory-editor').value;
|
|
btn.textContent = 'Saving…';
|
|
await apiPost('/api/memory', { content });
|
|
btn.textContent = '✓ Saved';
|
|
btn.classList.add('saved');
|
|
setTimeout(() => { btn.textContent = 'Save Memory'; btn.classList.remove('saved'); }, 2000);
|
|
}
|
|
window.saveMemory = saveMemory;
|
|
|
|
async function savePersona() {
|
|
const btn = $('#save-persona-btn');
|
|
const content = $('#persona-editor').value;
|
|
btn.textContent = 'Saving…';
|
|
await apiPost('/api/persona', { content });
|
|
btn.textContent = '✓ Saved';
|
|
btn.classList.add('saved');
|
|
setTimeout(() => { btn.textContent = 'Save Persona'; btn.classList.remove('saved'); }, 2000);
|
|
}
|
|
window.savePersona = savePersona;
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// PRODUCTIVITY
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderProductivity(el) {
|
|
const tasks = await apiFetch('/api/tasks');
|
|
|
|
const byStatus = { todo: [], 'in-progress': [], done: [], archived: [] };
|
|
tasks.forEach(t => {
|
|
if (byStatus[t.status]) byStatus[t.status].push(t);
|
|
});
|
|
|
|
const projects = [...new Set(tasks.map(t => t.project))];
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">📋 Productivity</h1>
|
|
<p class="page-subtitle">Task management — track your agent's workload</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card blue">
|
|
<div class="stat-label">To Do</div>
|
|
<div class="stat-value">${byStatus.todo.length}</div>
|
|
<span class="stat-badge blue">queued</span>
|
|
</div>
|
|
<div class="stat-card orange">
|
|
<div class="stat-label">In Progress</div>
|
|
<div class="stat-value">${byStatus['in-progress'].length}</div>
|
|
<span class="stat-badge orange">active</span>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Done</div>
|
|
<div class="stat-value">${byStatus.done.length}</div>
|
|
<span class="stat-badge green">completed</span>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="stat-label">Projects</div>
|
|
<div class="stat-value">${projects.length}</div>
|
|
<span class="stat-badge purple">tracked</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Add Task</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="productivity-add-form">
|
|
<input id="task-title" class="brain-input-full" type="text" placeholder="Task title…" />
|
|
<div class="brain-form-row">
|
|
<input id="task-description" class="brain-input-small" type="text" placeholder="Description (optional)" />
|
|
<select id="task-priority" class="brain-input-small">
|
|
<option value="low">Low</option>
|
|
<option value="medium" selected>Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="urgent">Urgent</option>
|
|
</select>
|
|
<input id="task-project" class="brain-input-small" type="text" placeholder="Project" />
|
|
<input id="task-due" class="brain-input-small" type="date" />
|
|
<button class="btn primary" onclick="addProductivityTask()">+ Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="productivity-columns">
|
|
${renderTaskColumn('📥 To Do', byStatus.todo, 'todo')}
|
|
${renderTaskColumn('🔄 In Progress', byStatus['in-progress'], 'in-progress')}
|
|
${renderTaskColumn('✅ Done', byStatus.done, 'done')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTaskColumn(title, tasks, status) {
|
|
return `
|
|
<div class="task-column">
|
|
<div class="task-column-header">
|
|
<span>${title}</span>
|
|
<span class="section-badge">${tasks.length}</span>
|
|
</div>
|
|
<div class="task-column-body">
|
|
${tasks.length > 0 ? tasks.map(t => renderProductivityCard(t)).join('') : `<div class="empty-state-small">No tasks</div>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderProductivityCard(task) {
|
|
const priorityColors = { low: 'green', medium: 'blue', high: 'orange', urgent: 'red' };
|
|
const nextStatus = { todo: 'in-progress', 'in-progress': 'done', done: 'archived' };
|
|
const nextLabel = { todo: 'Start →', 'in-progress': 'Done ✓', done: 'Archive' };
|
|
|
|
return `
|
|
<div class="productivity-card">
|
|
<div class="productivity-card-header">
|
|
<span class="priority-badge ${priorityColors[task.priority] || 'blue'}">${task.priority}</span>
|
|
${task.project ? `<span class="project-badge">${escapeHtml(task.project)}</span>` : ''}
|
|
</div>
|
|
<div class="productivity-card-title">${escapeHtml(task.title)}</div>
|
|
${task.description ? `<div class="productivity-card-desc">${escapeHtml(task.description)}</div>` : ''}
|
|
${task.dueDate ? `<div class="productivity-card-due">📅 ${task.dueDate}</div>` : ''}
|
|
<div class="productivity-card-actions">
|
|
<button class="btn-small" onclick="moveTask('${task.id}', '${nextStatus[task.status]}')">${nextLabel[task.status]}</button>
|
|
<button class="btn-small danger" onclick="deleteProductivityTask('${task.id}')">✕</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function addProductivityTask() {
|
|
const title = $('#task-title')?.value?.trim();
|
|
if (!title) return;
|
|
await apiPost('/api/tasks', {
|
|
title,
|
|
description: $('#task-description')?.value?.trim() || '',
|
|
priority: $('#task-priority')?.value || 'medium',
|
|
project: $('#task-project')?.value?.trim() || 'default',
|
|
dueDate: $('#task-due')?.value || null,
|
|
});
|
|
renderPage();
|
|
}
|
|
window.addProductivityTask = addProductivityTask;
|
|
|
|
async function moveTask(id, newStatus) {
|
|
await apiPost('/api/tasks/update', { id, status: newStatus });
|
|
renderPage();
|
|
}
|
|
window.moveTask = moveTask;
|
|
|
|
async function deleteProductivityTask(id) {
|
|
await apiDelete(`/api/tasks?id=${id}`);
|
|
renderPage();
|
|
}
|
|
window.deleteProductivityTask = deleteProductivityTask;
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// CONTENT INTEL
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderContentIntel(el) {
|
|
const items = await apiFetch('/api/content');
|
|
|
|
const byStatus = { queued: [], read: [], archived: [] };
|
|
items.forEach(i => {
|
|
if (byStatus[i.status]) byStatus[i.status].push(i);
|
|
});
|
|
|
|
const typeIcons = {
|
|
article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎',
|
|
};
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">📰 Content Intel</h1>
|
|
<p class="page-subtitle">Curate and track content for your agent</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card blue">
|
|
<div class="stat-label">Queued</div>
|
|
<div class="stat-value">${byStatus.queued.length}</div>
|
|
<span class="stat-badge blue">to read</span>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Read</div>
|
|
<div class="stat-value">${byStatus.read.length}</div>
|
|
<span class="stat-badge green">processed</span>
|
|
</div>
|
|
<div class="stat-card purple">
|
|
<div class="stat-label">Total</div>
|
|
<div class="stat-value">${items.length}</div>
|
|
<span class="stat-badge purple">items</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Add Content</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="brain-add-form">
|
|
<div class="brain-form-row">
|
|
<input id="content-title" class="brain-input-small" type="text" placeholder="Title" />
|
|
<input id="content-url" class="brain-input-small" type="url" placeholder="https://…" />
|
|
</div>
|
|
<div class="brain-form-row">
|
|
<select id="content-type" class="brain-input-small">
|
|
<option value="article">📄 Article</option>
|
|
<option value="video">🎬 Video</option>
|
|
<option value="tweet">🐦 Tweet</option>
|
|
<option value="paper">📑 Paper</option>
|
|
<option value="repo">💻 Repo</option>
|
|
<option value="other">📎 Other</option>
|
|
</select>
|
|
<input id="content-source" class="brain-input-small" type="text" placeholder="Source (e.g. HN, Twitter)" />
|
|
<input id="content-tags" class="brain-input-small" type="text" placeholder="Tags (comma sep)" />
|
|
<button class="btn primary" onclick="addContentItem()">+ Add</button>
|
|
</div>
|
|
<textarea id="content-summary" class="brain-input" placeholder="Summary or notes (optional)" rows="2"></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in" style="margin-top: 16px;">
|
|
<div class="section-header">
|
|
<span class="section-title">Content Library</span>
|
|
<span class="section-badge">${items.length} items</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="brain-search-row">
|
|
<input id="content-search" class="brain-input-full" type="text" placeholder="Search content…" oninput="searchContent()" />
|
|
</div>
|
|
<div id="content-list" class="content-list">
|
|
${renderContentItems(items, typeIcons)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderContentItems(items, typeIcons) {
|
|
if (items.length === 0) return '<div class="empty-state"><div class="empty-icon">📰</div><div class="empty-text">No content saved yet — add articles, papers, and more above</div></div>';
|
|
return items.map(item => `
|
|
<div class="content-card ${item.status}">
|
|
<div class="content-card-header">
|
|
<span class="content-type-icon">${typeIcons[item.type] || '📎'}</span>
|
|
<div class="content-card-info">
|
|
<div class="content-card-title">${item.url ? `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener">${escapeHtml(item.title || item.url)}</a>` : escapeHtml(item.title)}</div>
|
|
<div class="content-card-meta">${escapeHtml(item.source)} · ${formatTime(item.savedAt)}</div>
|
|
</div>
|
|
<div class="content-card-actions">
|
|
${item.status === 'queued' ? `<button class="btn-small" onclick="updateContent('${item.id}', 'read')">Mark Read</button>` : ''}
|
|
${item.status === 'read' ? `<button class="btn-small" onclick="updateContent('${item.id}', 'archived')">Archive</button>` : ''}
|
|
<button class="btn-small danger" onclick="deleteContent('${item.id}')">✕</button>
|
|
</div>
|
|
</div>
|
|
${item.summary ? `<div class="content-card-summary">${escapeHtml(item.summary)}</div>` : ''}
|
|
${item.tags.length > 0 ? `<div class="brain-fact-tags">${item.tags.map(t => `<span class="brain-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function addContentItem() {
|
|
const title = $('#content-title')?.value?.trim();
|
|
const url = $('#content-url')?.value?.trim();
|
|
if (!title && !url) return;
|
|
|
|
await apiPost('/api/content', {
|
|
title: title || url,
|
|
url: url || '',
|
|
type: $('#content-type')?.value || 'article',
|
|
source: $('#content-source')?.value?.trim() || 'manual',
|
|
summary: $('#content-summary')?.value?.trim() || '',
|
|
tags: ($('#content-tags')?.value || '').split(',').map(t => t.trim()).filter(Boolean),
|
|
});
|
|
|
|
// Reset
|
|
['content-title', 'content-url', 'content-source', 'content-tags', 'content-summary'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = '';
|
|
});
|
|
|
|
const items = await apiFetch('/api/content');
|
|
const listEl = $('#content-list');
|
|
if (listEl) listEl.innerHTML = renderContentItems(items, { article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎' });
|
|
}
|
|
window.addContentItem = addContentItem;
|
|
|
|
async function updateContent(id, status) {
|
|
await apiPost('/api/content/update', { id, status });
|
|
renderPage();
|
|
}
|
|
window.updateContent = updateContent;
|
|
|
|
async function deleteContent(id) {
|
|
await apiDelete(`/api/content?id=${id}`);
|
|
renderPage();
|
|
}
|
|
window.deleteContent = deleteContent;
|
|
|
|
async function searchContent() {
|
|
const query = $('#content-search')?.value?.trim() || '';
|
|
const url = query ? `/api/content?q=${encodeURIComponent(query)}` : '/api/content';
|
|
const items = await apiFetch(url);
|
|
const listEl = $('#content-list');
|
|
if (listEl) listEl.innerHTML = renderContentItems(items, { article: '📄', video: '🎬', tweet: '🐦', paper: '📑', repo: '💻', other: '📎' });
|
|
}
|
|
window.searchContent = searchContent;
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// TASKS (Scheduler)
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderTasks(el) {
|
|
const [cron, heartbeats, hooks] = await Promise.all([
|
|
apiFetch('/api/cron'),
|
|
apiFetch('/api/heartbeats'),
|
|
apiFetch('/api/hooks'),
|
|
]);
|
|
|
|
const allTasks = [
|
|
...cron.map(j => ({ ...j, taskType: 'cron', schedule: j.expression })),
|
|
...heartbeats.map(h => ({ ...h, taskType: 'heartbeat', schedule: `every ${h.intervalSeconds}s` })),
|
|
];
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">⏱️ Scheduler</h1>
|
|
<p class="page-subtitle">Scheduled cron jobs, heartbeat checks, and lifecycle hooks</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card purple">
|
|
<div class="stat-label">Cron Jobs</div>
|
|
<div class="stat-value">${cron.length}</div>
|
|
</div>
|
|
<div class="stat-card green">
|
|
<div class="stat-label">Heartbeat Checks</div>
|
|
<div class="stat-value">${heartbeats.length}</div>
|
|
</div>
|
|
<div class="stat-card orange">
|
|
<div class="stat-label">Hooks Defined</div>
|
|
<div class="stat-value">${Object.keys(hooks).filter(k => hooks[k]).length}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Scheduled Tasks</span>
|
|
<span class="section-badge">${allTasks.length} active</span>
|
|
</div>
|
|
<div class="section-body">
|
|
${allTasks.length > 0 ? `
|
|
<div class="task-list">
|
|
${allTasks.map(t => `
|
|
<div class="task-item">
|
|
<span class="task-type-badge ${t.taskType}">${t.taskType}</span>
|
|
<span class="task-name">${escapeHtml(t.name)}</span>
|
|
<span class="task-detail">${escapeHtml(t.instruction)}</span>
|
|
<span class="task-schedule">${escapeHtml(t.schedule)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : '<div class="empty-state"><div class="empty-icon">⏰</div><div class="empty-text">No scheduled tasks — define them in agents.md and heartbeat.md</div></div>'}
|
|
</div>
|
|
</div>
|
|
|
|
${Object.keys(hooks).filter(k => hooks[k]).length > 0 ? `
|
|
<div class="section-card fade-in" style="margin-top: 20px;">
|
|
<div class="section-header">
|
|
<span class="section-title">Lifecycle Hooks</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="task-list">
|
|
${Object.entries(hooks).filter(([, v]) => v).map(([hookType, instruction]) => `
|
|
<div class="task-item">
|
|
<span class="task-type-badge cron">hook</span>
|
|
<span class="task-name">${escapeHtml(hookType)}</span>
|
|
<span class="task-detail">${escapeHtml(instruction)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// CONNECTIONS
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderConnections(el) {
|
|
const connections = await apiFetch('/api/connections');
|
|
const active = connections.filter(c => c.status === 'active').length;
|
|
|
|
const iconMap = {
|
|
discord: '🎮', claude: '🤖', codex: '💻', gemini: '✨',
|
|
opencode: '🔧', heartbeat: '💚', cron: '🕐', ipc: '📨', skills: '⚡',
|
|
};
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Connections</h1>
|
|
<p class="page-subtitle">All integrations and services powering your agent</p>
|
|
</div>
|
|
|
|
<div class="progress-bar-container fade-in">
|
|
<div class="progress-label">
|
|
<span>${active} / ${connections.length} connected</span>
|
|
<span>${Math.round((active / connections.length) * 100)}%</span>
|
|
</div>
|
|
<div class="progress-track">
|
|
<div class="progress-fill" style="width: ${(active / connections.length) * 100}%;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="connections-grid">
|
|
${connections.map(c => `
|
|
<div class="connection-card ${c.status === 'inactive' ? 'inactive' : ''}">
|
|
<div class="connection-logo ${c.logo}">
|
|
${iconMap[c.logo] || '🔌'}
|
|
</div>
|
|
<div class="connection-info">
|
|
<div class="connection-name">${escapeHtml(c.name)}</div>
|
|
<div class="connection-detail">${escapeHtml(c.detail)}</div>
|
|
</div>
|
|
<span class="connection-status ${c.status}">${c.status}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// SETTINGS
|
|
// ═══════════════════════════════════════════════════════════
|
|
async function renderSettings(el) {
|
|
const config = await apiFetch('/api/config');
|
|
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
|
|
|
|
el.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">Settings</h1>
|
|
<p class="page-subtitle">Agent configuration and quick todos</p>
|
|
</div>
|
|
|
|
<div class="section-grid">
|
|
<div class="section-card fade-in">
|
|
<div class="section-header">
|
|
<span class="section-title">Configuration</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="config-table">
|
|
${Object.entries(config).map(([k, v]) => renderConfigRow(formatConfigKey(k), v)).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-card fade-in" style="animation-delay: 0.1s; opacity: 0;">
|
|
<div class="section-header">
|
|
<span class="section-title">Quick Todos</span>
|
|
<span class="section-badge">${todos.filter(t => !t.done).length} pending</span>
|
|
</div>
|
|
<div class="section-body">
|
|
<div class="todo-input-row">
|
|
<input class="todo-input" id="todo-input" type="text" placeholder="Add a todo…" onkeydown="if(event.key==='Enter')addTodo()" />
|
|
<button class="btn primary" onclick="addTodo()">Add</button>
|
|
</div>
|
|
<div class="todo-list" id="todo-list">
|
|
${todos.map((t, i) => renderTodoItem(t, i)).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function addTodo() {
|
|
const input = $('#todo-input');
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
|
|
todos.push({ text, done: false });
|
|
localStorage.setItem('mc-todos', JSON.stringify(todos));
|
|
input.value = '';
|
|
renderPage();
|
|
}
|
|
window.addTodo = addTodo;
|
|
|
|
function toggleTodo(index) {
|
|
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
|
|
if (todos[index]) {
|
|
todos[index].done = !todos[index].done;
|
|
localStorage.setItem('mc-todos', JSON.stringify(todos));
|
|
renderPage();
|
|
}
|
|
}
|
|
window.toggleTodo = toggleTodo;
|
|
|
|
function deleteTodo(index) {
|
|
const todos = JSON.parse(localStorage.getItem('mc-todos') || '[]');
|
|
todos.splice(index, 1);
|
|
localStorage.setItem('mc-todos', JSON.stringify(todos));
|
|
renderPage();
|
|
}
|
|
window.deleteTodo = deleteTodo;
|
|
|
|
function renderTodoItem(todo, index) {
|
|
return `
|
|
<div class="todo-item">
|
|
<div class="todo-checkbox ${todo.done ? 'checked' : ''}" onclick="toggleTodo(${index})"></div>
|
|
<span class="todo-text ${todo.done ? 'completed' : ''}">${escapeHtml(todo.text)}</span>
|
|
<button class="todo-delete" onclick="deleteTodo(${index})">✕</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// SHARED RENDER HELPERS
|
|
// ═══════════════════════════════════════════════════════════
|
|
function renderActivityItem(item) {
|
|
const icons = { message: '💬', heartbeat: '💚', cron: '🕐', hook: '🪝', webhook: '🌐' };
|
|
return `
|
|
<div class="activity-item">
|
|
<div class="activity-icon ${item.type}">
|
|
${icons[item.type] || '⚡'}
|
|
</div>
|
|
<div class="activity-body">
|
|
<div class="activity-title">${capitalize(item.type)} — ${escapeHtml(item.source)}</div>
|
|
<div class="activity-detail">${escapeHtml(item.detail)}</div>
|
|
</div>
|
|
<div class="activity-time">${formatTime(item.timestamp)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderConfigRow(key, value) {
|
|
return `
|
|
<div class="config-row">
|
|
<span class="config-key">${escapeHtml(key)}</span>
|
|
<span class="config-value">${escapeHtml(String(value ?? '—'))}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatConfigKey(key) {
|
|
return key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// UTILITIES
|
|
// ═══════════════════════════════════════════════════════════
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function capitalize(str) {
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
function formatTime(isoStr) {
|
|
try {
|
|
const d = new Date(isoStr);
|
|
const now = new Date();
|
|
const diffMs = now - d;
|
|
if (diffMs < 60000) return `${Math.floor(diffMs / 1000)}s ago`;
|
|
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
|
|
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
|
|
return d.toLocaleDateString();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|