v0.3.0: Event-driven scheduler, error dashboard, persistent memory, PWA
Phase 1 — Event-Driven Scheduler:
- Rewrote scheduler/scheduler.py with file watcher + cron engine
- Job auto-reload on file changes via watchdog-style polling
- Execution history tracking (data/scheduler-history.json)
- Webhook receiver endpoint: POST /api/webhook
- Scheduler events endpoint: GET /api/scheduler/events
- Manual job trigger: POST /api/scheduler/trigger/{id}
- Generic catch-all webhook: POST /api/webhook/generic
- Scheduler auto-starts with server via lifespan hooks
Phase 2 — Error Dashboard + Circuit Breaker:
- Error tracking endpoints: GET/POST/DELETE /api/errors
- Circuit breaker pattern: /api/circuit-breaker (trip/reset)
- New dashboard/pages/errors.js — error log + circuit breaker status
- Error categories: agent, skill, api, system, general
- Global error count badge in sidebar
- Circuit breaker auto-recovery (half-open after 300s)
Phase 3 — Persistent Memory + Auto-Skill-Generator:
- brain/memory_search.py — SQLite FTS5 full-text search module
- Indexes brain/*.md, skills/*/*.md, brain/journal/*.md
- Entity extraction (persons, emails, URLs, acronyms, IPs)
- Search endpoint: GET /api/memory/search?q=
- Reindex endpoint: POST /api/memory/reindex
- Entity listing: GET /api/memory/entities
- Auto-skill-generator: POST /api/skills/generate (NL → SKILL.md)
Phase 4 — Mobile PWA:
- manifest.json at /manifest.json
- Service worker at /sw.js (offline fallback)
- Bottom navigation bar for mobile (<768px)
- PWA meta tags (theme-color, apple-mobile-web-app)
- Mobile responsive CSS (grid collapse, touch targets, safe-area)
- Service worker registration in index.html
- Input font-size 16px on touch devices (prevents iOS zoom)
This commit is contained in:
parent
a30162d114
commit
e003688658
|
|
@ -11,3 +11,9 @@ audit/*
|
|||
data/settings.json
|
||||
data/chat-history.json
|
||||
data/cost-history.json
|
||||
data/scheduler-history.json
|
||||
data/error-log.json
|
||||
data/circuit-breaker.json
|
||||
data/memory.db
|
||||
data/kanban/*.json
|
||||
scheduler/*.pyc
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
"""Agentic OS — Persistent Memory with SQLite FTS5
|
||||
|
||||
Full-text search across brain files, skills, journal, and prompts.
|
||||
Auto-indexes text content on startup and provides search + entity extraction.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
DB_PATH = BASE_DIR.parent / "data" / "memory.db"
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
def _get_db():
|
||||
if not hasattr(_local, "conn") or _local.conn is None:
|
||||
_local.conn = sqlite3.connect(str(DB_PATH))
|
||||
_local.conn.row_factory = sqlite3.Row
|
||||
return _local.conn
|
||||
|
||||
def init_db():
|
||||
conn = _get_db()
|
||||
conn.executescript("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
||||
id, source, path, title, content, category,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS memory_meta (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
path TEXT,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
created TEXT,
|
||||
updated TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
context TEXT,
|
||||
source TEXT,
|
||||
created TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_source ON memory_meta(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_meta_category ON memory_meta(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def index_text(source: str, path: str, title: str, content: str, category: str = "general"):
|
||||
conn = _get_db()
|
||||
doc_id = str(uuid.uuid4())[:8]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO memory_meta (id, source, path, title, category, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(doc_id, source, path, title, category, now, now)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO memory_fts (id, source, path, title, content, category) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(doc_id, source, path, title, content, category)
|
||||
)
|
||||
conn.commit()
|
||||
return doc_id
|
||||
|
||||
def search(query: str, limit: int = 20) -> list:
|
||||
conn = _get_db()
|
||||
if not query.strip():
|
||||
return []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT m.id, m.source, m.path, m.title, m.category, m.created, "
|
||||
"snippet(memory_fts, 4, '<mark>', '</mark>', '...', 32) as snippet "
|
||||
"FROM memory_fts JOIN memory_meta m ON memory_fts.id = m.id "
|
||||
"WHERE memory_fts MATCH ? ORDER BY rank LIMIT ?",
|
||||
(query, limit)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
|
||||
def index_brain_files():
|
||||
brain_dir = BASE_DIR
|
||||
for f in brain_dir.glob("*.md"):
|
||||
content = f.read_text(encoding="utf-8")
|
||||
title = f.stem.replace("-", " ").replace("_", " ").title()
|
||||
index_text("brain", str(f.relative_to(BASE_DIR.parent)), title, content, "brain")
|
||||
|
||||
def index_skills():
|
||||
skills_dir = BASE_DIR.parent / "skills"
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
for f in d.glob("*.md"):
|
||||
content = f.read_text(encoding="utf-8")
|
||||
index_text("skill", str(f.relative_to(BASE_DIR.parent)), f"{d.name}/{f.stem}", content, "skill")
|
||||
|
||||
def index_journal():
|
||||
journal_dir = BASE_DIR / "journal"
|
||||
if journal_dir.exists():
|
||||
for f in sorted(journal_dir.glob("*.md")):
|
||||
content = f.read_text(encoding="utf-8")
|
||||
index_text("journal", str(f.relative_to(BASE_DIR.parent)), f"Journal {f.stem}", content, "journal")
|
||||
|
||||
def reindex_all():
|
||||
conn = _get_db()
|
||||
conn.executescript("DELETE FROM memory_fts; DELETE FROM memory_meta;")
|
||||
conn.commit()
|
||||
index_brain_files()
|
||||
index_skills()
|
||||
index_journal()
|
||||
|
||||
def extract_entities(text: str) -> list:
|
||||
entities = []
|
||||
patterns = [
|
||||
(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', 'person'),
|
||||
(r'\b[\w.+-]+@[\w-]+\.[\w.-]+\b', 'email'),
|
||||
(r'\bhttps?://[^\s<>"]+\b', 'url'),
|
||||
(r'\b[A-Z]{2,}\b', 'acronym'),
|
||||
(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', 'ip'),
|
||||
]
|
||||
seen = set()
|
||||
for pattern, etype in patterns:
|
||||
for match in re.finditer(pattern, text):
|
||||
value = match.group()
|
||||
if value not in seen:
|
||||
seen.add(value)
|
||||
entities.append({"value": value, "type": etype})
|
||||
return entities
|
||||
|
||||
def save_entities(entities: list, source: str = "auto"):
|
||||
conn = _get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for ent in entities:
|
||||
eid = str(uuid.uuid4())[:8]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO entities (id, name, type, context, source, created) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(eid, ent["value"], ent["type"], ent.get("context", ""), source, now)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_entities(entity_type: str = "", limit: int = 50) -> list:
|
||||
conn = _get_db()
|
||||
if entity_type:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT name, type, COUNT(*) as count FROM entities WHERE type = ? GROUP BY name ORDER BY count DESC LIMIT ?",
|
||||
(entity_type, limit)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT name, type, COUNT(*) as count FROM entities GROUP BY name ORDER BY count DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# Initialize on import
|
||||
init_db()
|
||||
|
|
@ -89,4 +89,18 @@ const api = {
|
|||
// Session Replay
|
||||
listSessions: () => api.get('/api/sessions/list'),
|
||||
getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
|
||||
// v0.3.0: Scheduler Events
|
||||
getSchedulerEvents: (limit) => api.get(`/api/scheduler/events?limit=${limit || 50}`),
|
||||
triggerJob: (id) => api.post(`/api/scheduler/trigger/${encodeURIComponent(id)}`, {}),
|
||||
sendWebhook: (data) => api.post('/api/webhook', data),
|
||||
// v0.3.0: Error Tracking
|
||||
getErrors: (limit, category) => api.get(`/api/errors?limit=${limit || 50}${category ? `&category=${encodeURIComponent(category)}` : ''}`),
|
||||
reportError: (data) => api.post('/api/errors/report', data),
|
||||
clearErrors: () => api.del('/api/errors'),
|
||||
// v0.3.0: Circuit Breaker
|
||||
getCircuitBreaker: () => api.get('/api/circuit-breaker'),
|
||||
tripCircuitBreaker: (agent) => api.post('/api/circuit-breaker/trip', { agent }),
|
||||
resetCircuitBreaker: (agent) => api.post('/api/circuit-breaker/reset', { agent }),
|
||||
// v0.3.0: PWA
|
||||
getManifest: () => api.get('/manifest.json'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async function navigate(page) {
|
|||
const bar = document.getElementById('topLoadingBar');
|
||||
if (bar) { bar.classList.add('active'); bar.style.width = '30%'; }
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item, .bottom-nav-item').forEach(el => el.classList.remove('active'));
|
||||
const navItem = document.querySelector(`[data-page="${hash}"]`);
|
||||
if (navItem) navItem.classList.add('active');
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agentic OS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Agentic OS">
|
||||
<meta name="theme-color" content="#6c5ce7">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
|
|
@ -48,6 +53,7 @@
|
|||
<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>
|
||||
<a href="#errors" class="nav-item" data-page="errors"><span class="nav-icon">⚠</span><span class="nav-label">Error Dashboard</span><span class="nav-badge" id="errorCount">0</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>
|
||||
|
|
@ -88,8 +94,21 @@
|
|||
<div id="toastContainer" class="toast-container"></div>
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<nav id="bottomNav" class="bottom-nav">
|
||||
<a href="#chat" class="bottom-nav-item" data-page="chat"><span class="bottom-nav-icon">💬</span><span class="bottom-nav-label">Chat</span></a>
|
||||
<a href="#dashboard" class="bottom-nav-item" data-page="dashboard"><span class="bottom-nav-icon">◉</span><span class="bottom-nav-label">Home</span></a>
|
||||
<a href="#skills" class="bottom-nav-item" data-page="skills"><span class="bottom-nav-icon">⚡</span><span class="bottom-nav-label">Skills</span></a>
|
||||
<a href="#kanban" class="bottom-nav-item" data-page="kanban"><span class="bottom-nav-icon">📌</span><span class="bottom-nav-label">Board</span></a>
|
||||
<a href="#errors" class="bottom-nav-item" data-page="errors"><span class="bottom-nav-icon">⚠</span><span class="bottom-nav-label">Errors</span></a>
|
||||
</nav>
|
||||
|
||||
<script src="utils.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
async function renderErrors() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="page-title">Error Dashboard</div>
|
||||
<div class="page-subtitle">Track and manage system errors across all agents</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-ghost" onclick="refreshErrors()">🔄 Refresh</button>
|
||||
<button class="btn btn-danger" onclick="clearAllErrors()">🗑 Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mb-3" style="flex-wrap:wrap">
|
||||
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||
<div class="metric-tile-value" id="errorTotalCount">0</div>
|
||||
<div class="metric-tile-label">Total Errors</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||
<div class="metric-tile-value" id="errorAgentCount">0</div>
|
||||
<div class="metric-tile-label">Agent Errors</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||
<div class="metric-tile-value" id="errorSkillCount">0</div>
|
||||
<div class="metric-tile-label">Skill Errors</div>
|
||||
</div>
|
||||
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||
<div class="metric-tile-value" id="errorCircuitCount">0</div>
|
||||
<div class="metric-tile-label">Circuit Breaks</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-3" style="flex-wrap:wrap">
|
||||
<select id="errorCategoryFilter" class="form-select" style="width:160px" onchange="refreshErrors()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="skill">Skill</option>
|
||||
<option value="api">API</option>
|
||||
<option value="system">System</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
<span id="errorCountBadge" class="badge badge-danger" style="display:none"></span>
|
||||
</div>
|
||||
<div id="errorList"><div class="loading"><div class="loading-spinner"></div></div></div>
|
||||
<div class="section-title mt-4">Circuit Breaker Status</div>
|
||||
<div id="circuitBreakerCards" class="grid grid-3"></div>
|
||||
`;
|
||||
await Promise.all([refreshErrors(), loadCircuitBreaker()]);
|
||||
}
|
||||
|
||||
async function refreshErrors() {
|
||||
const container = document.getElementById('errorList');
|
||||
if (!container) return;
|
||||
const category = document.getElementById('errorCategoryFilter')?.value || '';
|
||||
try {
|
||||
const data = await api.getErrors(100, category);
|
||||
const errors = data.errors || [];
|
||||
const totalEl = document.getElementById('errorTotalCount');
|
||||
if (totalEl) totalEl.textContent = errors.length;
|
||||
const agentErrors = errors.filter(e => e.category === 'agent').length;
|
||||
const skillErrors = errors.filter(e => e.category === 'skill').length;
|
||||
const circuitErrors = errors.filter(e => e.category === 'circuit').length;
|
||||
const aEl = document.getElementById('errorAgentCount');
|
||||
if (aEl) aEl.textContent = agentErrors;
|
||||
const sEl = document.getElementById('errorSkillCount');
|
||||
if (sEl) sEl.textContent = skillErrors;
|
||||
const cEl = document.getElementById('errorCircuitCount');
|
||||
if (cEl) cEl.textContent = circuitErrors;
|
||||
const badge = document.getElementById('errorCountBadge');
|
||||
if (badge) {
|
||||
if (errors.length > 0) { badge.style.display = 'inline'; badge.textContent = errors.length + ' issues'; }
|
||||
else { badge.style.display = 'none'; }
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">✅</div><div class="empty-state-title">No errors</div><div class="empty-state-desc">System is running smoothly</div></div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Category</th><th>Source</th><th>Message</th><th>ID</th></tr></thead>
|
||||
<tbody>
|
||||
${errors.slice().reverse().map(e => `
|
||||
<tr>
|
||||
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
|
||||
<td><span class="badge ${e.category === 'agent' ? 'badge-danger' : e.category === 'skill' ? 'badge-warning' : e.category === 'api' ? 'badge-info' : 'badge'}">${escapeHtml(e.category)}</span></td>
|
||||
<td style="font-size:13px"><strong>${escapeHtml(e.source)}</strong></td>
|
||||
<td style="font-size:13px">${escapeHtml(e.message)}</td>
|
||||
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);text-align:right;margin-top:8px">${errors.length} error${errors.length !== 1 ? 's' : ''}</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
if (container) container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllErrors() {
|
||||
if (!confirm('Clear all error logs?')) return;
|
||||
try {
|
||||
await api.clearErrors();
|
||||
showToast('Error log cleared', 'success');
|
||||
refreshErrors();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCircuitBreaker() {
|
||||
const container = document.getElementById('circuitBreakerCards');
|
||||
if (!container) return;
|
||||
try {
|
||||
const data = await api.getCircuitBreaker();
|
||||
const agents = data.agents || {};
|
||||
const agentNames = Object.keys(agents);
|
||||
if (agentNames.length === 0) {
|
||||
container.innerHTML = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No circuit breaker data</div></div></div>';
|
||||
return;
|
||||
}
|
||||
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
|
||||
container.innerHTML = agentNames.map(a => {
|
||||
const cb = agents[a] || {};
|
||||
const isOpen = cb.state === 'open';
|
||||
return `
|
||||
<div class="card" style="border-color:${isOpen ? 'var(--red)' : 'var(--green-dim)'}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:20px">${agentIcons[a] || '🤖'}</span>
|
||||
<div>
|
||||
<div style="font-weight:600;text-transform:capitalize">${escapeHtml(a)}</div>
|
||||
<div style="font-size:12px;color:${isOpen ? 'var(--red)' : 'var(--green)'}">${cb.state || 'closed'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;font-size:12px;color:var(--text-muted);margin-bottom:8px">
|
||||
<span>Failures: ${cb.failures || 0}</span>
|
||||
<span>Threshold: ${data.threshold || 3}</span>
|
||||
</div>
|
||||
${isOpen ? `<button class="btn btn-sm btn-primary" onclick="resetCircuit('${a}')">🔓 Reset</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch {
|
||||
container.innerHTML = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load circuit breaker</div></div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCircuit(agent) {
|
||||
try {
|
||||
await api.resetCircuitBreaker(agent);
|
||||
showToast(`Circuit breaker reset for ${agent}`, 'success');
|
||||
loadCircuitBreaker();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
|
@ -1352,6 +1352,77 @@ pre {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Bottom Navigation (PWA mobile) */
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 10px;
|
||||
transition: var(--transition);
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
.bottom-nav-item:active {
|
||||
background: var(--accent-glow);
|
||||
}
|
||||
.bottom-nav-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav { display: flex; }
|
||||
.main-content { padding-bottom: 80px; }
|
||||
.sidebar { display: none; }
|
||||
.topbar { padding: 10px 16px; }
|
||||
.topbar-title { font-size: 16px; }
|
||||
.grid { grid-template-columns: 1fr !important; }
|
||||
.grid-2 { grid-template-columns: 1fr !important; }
|
||||
.grid-3 { grid-template-columns: 1fr !important; }
|
||||
.grid-4 { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.form-row { flex-direction: column; }
|
||||
.kanban-board { flex-direction: column; overflow-x: hidden; }
|
||||
.kanban-column { min-width: 100%; max-height: 300px; }
|
||||
.page-header { flex-direction: column; gap: 8px; }
|
||||
.btn-group { width: 100%; }
|
||||
.btn-group .btn, .btn-group .form-input, .btn-group .form-select { flex: 1; }
|
||||
.metric-tile { padding: 12px; }
|
||||
.stat-value { font-size: 20px; }
|
||||
.table-wrapper { overflow-x: auto; }
|
||||
.card { padding: 14px; }
|
||||
}
|
||||
|
||||
/* Touch-friendly: larger tap targets */
|
||||
@media (pointer: coarse) {
|
||||
.nav-item, .btn, .bottom-nav-item { min-height: 44px; }
|
||||
.form-input, .form-select, .form-textarea { font-size: 16px; }
|
||||
input, select, textarea, button { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,60 +1,205 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Agentic OS — APScheduler engine for recurring tasks"""
|
||||
"""Agentic OS — Event-Driven Scheduler Engine
|
||||
|
||||
File watcher + cron-based scheduler with execution history.
|
||||
Handles job reloading, webhook triggers, skill execution events.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
except ImportError:
|
||||
print("Install APScheduler: pip install apscheduler")
|
||||
sys.exit(1)
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
JOBS_DIR = BASE_DIR / "jobs"
|
||||
HISTORY_FILE = BASE_DIR.parent / "data" / "scheduler-history.json"
|
||||
|
||||
def run_skill(skill_name: str):
|
||||
"""Execute a skill by invoking the appropriate agent."""
|
||||
_event_listeners = []
|
||||
_on_files_changed = []
|
||||
|
||||
def on_event(listener: Callable):
|
||||
_event_listeners.append(listener)
|
||||
return listener
|
||||
|
||||
def on_files_changed(cb: Callable):
|
||||
_on_files_changed.append(cb)
|
||||
return cb
|
||||
|
||||
def emit_event(event: dict):
|
||||
event["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
event["id"] = str(uuid.uuid4())[:8]
|
||||
for listener in _event_listeners:
|
||||
try:
|
||||
listener(event)
|
||||
except Exception:
|
||||
pass
|
||||
_save_history(event)
|
||||
|
||||
def _save_history(event: dict):
|
||||
history = []
|
||||
if HISTORY_FILE.exists():
|
||||
history = json.loads(HISTORY_FILE.read_text())
|
||||
history.append(event)
|
||||
if len(history) > 1000:
|
||||
history = history[-1000:]
|
||||
HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
||||
|
||||
def get_history(limit: int = 100) -> list:
|
||||
if not HISTORY_FILE.exists():
|
||||
return []
|
||||
history = json.loads(HISTORY_FILE.read_text())
|
||||
return history[-limit:]
|
||||
|
||||
def load_job_definitions() -> list:
|
||||
jobs = []
|
||||
for f in sorted(JOBS_DIR.glob("*.json")):
|
||||
data = json.loads(f.read_text())
|
||||
data["_file"] = str(f)
|
||||
jobs.append(data)
|
||||
return jobs
|
||||
|
||||
def get_job_by_id(job_id: str) -> Optional[dict]:
|
||||
for job in load_job_definitions():
|
||||
if job.get("id") == job_id:
|
||||
return job
|
||||
return None
|
||||
|
||||
def get_job_by_name(name: str) -> Optional[dict]:
|
||||
for job in load_job_definitions():
|
||||
if job.get("name") == name:
|
||||
return job
|
||||
return None
|
||||
|
||||
def run_skill(skill_name: str, trigger: str = "scheduler", input_text: str = ""):
|
||||
"""Execute a skill via the API."""
|
||||
audit_file = BASE_DIR.parent / "audit" / "audit.log"
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
entry = {
|
||||
"action": "scheduler_run",
|
||||
"skill": skill_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"trigger": trigger,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
with open(audit_file, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}")
|
||||
emit_event({
|
||||
"type": "skill_run",
|
||||
"skill": skill_name,
|
||||
"trigger": trigger,
|
||||
"status": "started",
|
||||
})
|
||||
print(f"[{timestamp}] Skill '{skill_name}' triggered by {trigger}")
|
||||
return {"status": "triggered", "skill": skill_name, "trigger": trigger}
|
||||
|
||||
def load_jobs(scheduler: BackgroundScheduler):
|
||||
"""Load job definitions from jobs/ directory."""
|
||||
for job_file in JOBS_DIR.glob("*.json"):
|
||||
data = json.loads(job_file.read_text())
|
||||
if not data.get("enabled", True):
|
||||
continue
|
||||
scheduler.add_job(
|
||||
run_skill,
|
||||
CronTrigger.from_crontab(data["cron"]),
|
||||
args=[data["skill"]],
|
||||
id=data.get("id", data["name"]),
|
||||
name=data["name"],
|
||||
replace_existing=True,
|
||||
)
|
||||
print(f" Scheduled: {data['name']} ({data['cron']})")
|
||||
|
||||
# ─── File Watcher ─────────────────────────────────────────────
|
||||
|
||||
class JobFileWatcher:
|
||||
"""Watch scheduler/jobs/ for changes and notify listeners."""
|
||||
def __init__(self, interval: float = 2.0):
|
||||
self.interval = interval
|
||||
self._known = {}
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
self._scan()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def _scan(self):
|
||||
current = {}
|
||||
for f in JOBS_DIR.glob("*.json"):
|
||||
try:
|
||||
mtime = f.stat().st_mtime
|
||||
current[str(f)] = mtime
|
||||
except OSError:
|
||||
pass
|
||||
if self._known and current != self._known:
|
||||
for cb in _on_files_changed:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
pass
|
||||
self._known = current
|
||||
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
time.sleep(self.interval)
|
||||
self._scan()
|
||||
|
||||
|
||||
# ─── Cron Scheduler ────────────────────────────────────────────
|
||||
|
||||
class CronScheduler:
|
||||
"""Simple in-process cron scheduler using APScheduler."""
|
||||
def __init__(self):
|
||||
self._scheduler = None
|
||||
self._watcher = JobFileWatcher()
|
||||
|
||||
def start(self):
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler as BS
|
||||
from apscheduler.triggers.cron import CronTrigger as CT
|
||||
except ImportError:
|
||||
print("Install APScheduler: pip install apscheduler")
|
||||
return
|
||||
self._scheduler = BS()
|
||||
self._reload_jobs()
|
||||
self._scheduler.start()
|
||||
self._watcher.start()
|
||||
_on_files_changed.append(self._reload_jobs)
|
||||
print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}")
|
||||
|
||||
def stop(self):
|
||||
self._watcher.stop()
|
||||
if self._scheduler:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
|
||||
def _reload_jobs(self):
|
||||
if not self._scheduler:
|
||||
return
|
||||
from apscheduler.triggers.cron import CronTrigger as CT
|
||||
for job in self._scheduler.get_jobs():
|
||||
job.remove()
|
||||
for data in load_job_definitions():
|
||||
if not data.get("enabled", True):
|
||||
continue
|
||||
try:
|
||||
self._scheduler.add_job(
|
||||
run_skill,
|
||||
CT.from_crontab(data["cron"]),
|
||||
args=[data["skill"], "cron"],
|
||||
id=data.get("id", data["name"]),
|
||||
name=data["name"],
|
||||
replace_existing=True,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" Failed to schedule {data.get('name')}: {e}")
|
||||
count = len(self._scheduler.get_jobs())
|
||||
print(f" Scheduled {count} jobs")
|
||||
|
||||
|
||||
# ─── Standalone Entry ─────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
scheduler = BackgroundScheduler()
|
||||
load_jobs(scheduler)
|
||||
scheduler = CronScheduler()
|
||||
scheduler.start()
|
||||
print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}")
|
||||
try:
|
||||
while True:
|
||||
import time
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
scheduler.shutdown()
|
||||
scheduler.stop()
|
||||
print("Scheduler stopped.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
254
server.py
254
server.py
|
|
@ -6,6 +6,7 @@ Multi-agent orchestration server for opencode, Hermes, Gemini CLI
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
|
@ -528,6 +529,200 @@ def update_settings(data: SettingsUpdate):
|
|||
append_audit({"action": "settings_updated"})
|
||||
return {"status": "ok"}
|
||||
|
||||
# ─── Routes: Webhooks & Scheduler Events (v0.3.0) ─────────────────
|
||||
|
||||
@app.post("/api/webhook")
|
||||
def webhook_receiver(data: dict):
|
||||
"""Generic webhook receiver — triggers skill execution by event type."""
|
||||
event_type = data.get("event", data.get("type", "unknown"))
|
||||
skill_name = data.get("skill", "")
|
||||
payload = data.get("payload", {})
|
||||
if skill_name:
|
||||
from scheduler.scheduler import run_skill
|
||||
result = run_skill(skill_name, trigger=f"webhook:{event_type}", input_text=json.dumps(payload))
|
||||
append_audit({"action": "webhook_received", "event": event_type, "skill": skill_name})
|
||||
return {"status": "processed", "event": event_type, "skill": skill_name, "result": result}
|
||||
append_audit({"action": "webhook_received", "event": event_type})
|
||||
return {"status": "received", "event": event_type}
|
||||
|
||||
@app.get("/api/scheduler/events")
|
||||
def get_scheduler_events(limit: int = Query(50, le=200)):
|
||||
from scheduler.scheduler import get_history
|
||||
return {"events": get_history(limit=limit)}
|
||||
|
||||
@app.post("/api/scheduler/trigger/{job_id}")
|
||||
def trigger_job(job_id: str):
|
||||
from scheduler.scheduler import get_job_by_id, run_skill
|
||||
job = get_job_by_id(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
result = run_skill(job["skill"], trigger="manual")
|
||||
append_audit({"action": "job_triggered", "job_id": job_id, "skill": job["skill"]})
|
||||
return result
|
||||
|
||||
@app.post("/api/webhook/generic")
|
||||
def generic_webhook(data: dict):
|
||||
"""Catch-all webhook receiver for external tool integrations."""
|
||||
source = data.get("source", "unknown")
|
||||
event = data.get("event", data.get("action", "trigger"))
|
||||
skill = data.get("skill", "")
|
||||
if skill:
|
||||
from scheduler.scheduler import run_skill
|
||||
run_skill(skill, trigger=f"webhook:{source}:{event}")
|
||||
append_audit({"action": "generic_webhook", "source": source, "event": event, "skill": skill})
|
||||
return {"status": "ok", "source": source, "event": event}
|
||||
|
||||
# ─── Routes: Memory Search & Auto-Skill Generator (v0.3.0) ─────────
|
||||
|
||||
@app.get("/api/memory/search")
|
||||
def memory_search(q: str = Query(""), limit: int = Query(20, le=100)):
|
||||
from brain.memory_search import search, extract_entities
|
||||
results = search(q, limit) if q else []
|
||||
entities = extract_entities(q) if q else []
|
||||
return {"results": results, "entities": entities, "query": q}
|
||||
|
||||
@app.post("/api/memory/reindex")
|
||||
def memory_reindex():
|
||||
from brain.memory_search import reindex_all
|
||||
reindex_all()
|
||||
append_audit({"action": "memory_reindexed"})
|
||||
return {"status": "reindexed"}
|
||||
|
||||
@app.get("/api/memory/entities")
|
||||
def list_entities(entity_type: str = "", limit: int = Query(50, le=200)):
|
||||
from brain.memory_search import get_entities
|
||||
return {"entities": get_entities(entity_type=entity_type, limit=limit)}
|
||||
|
||||
@app.post("/api/skills/generate")
|
||||
def generate_skill(data: dict):
|
||||
"""Auto-generate a SKILL.md from a natural language description."""
|
||||
name = data.get("name", "").strip().lower().replace(" ", "-")
|
||||
description = data.get("description", "").strip()
|
||||
if not name or not description:
|
||||
raise HTTPException(400, "Both 'name' and 'description' are required")
|
||||
if not re.match(r'^[a-z0-9-]+$', name):
|
||||
raise HTTPException(400, "Skill name must be alphanumeric with hyphens")
|
||||
skill_dir = BASE_DIR / "skills" / name
|
||||
if skill_dir.exists():
|
||||
raise HTTPException(409, "Skill already exists")
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "context").mkdir(exist_ok=True)
|
||||
skill_md = f"""# {description}
|
||||
|
||||
{description}
|
||||
|
||||
## Usage
|
||||
Generate this skill by running it with appropriate input.
|
||||
|
||||
## Input
|
||||
- Natural language description of what to do
|
||||
|
||||
## Output
|
||||
- Executed task result
|
||||
|
||||
## Primary: opencode
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(skill_md)
|
||||
(skill_dir / "learnings.md").write_text(f"# {name}\n\nAuto-generated skill.\n")
|
||||
eval_data = {"criteria": ["completeness", "accuracy", "efficiency"], "weights": [0.4, 0.3, 0.3]}
|
||||
(skill_dir / "eval.json").write_text(json.dumps(eval_data, indent=2))
|
||||
(skill_dir / "score-history.json").write_text("[]")
|
||||
append_audit({"action": "skill_generated", "name": name, "description": description})
|
||||
return {"status": "created", "name": name, "skill": skill_md}
|
||||
|
||||
# ─── Routes: Error Tracking (v0.3.0) ───────────────────────────────
|
||||
|
||||
ERROR_LOG_FILE = BASE_DIR / "data" / "error-log.json"
|
||||
|
||||
def log_error(source: str, message: str, category: str = "general", details: dict = None):
|
||||
errors = []
|
||||
if ERROR_LOG_FILE.exists():
|
||||
errors = json.loads(ERROR_LOG_FILE.read_text())
|
||||
errors.append({
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"source": source,
|
||||
"message": message,
|
||||
"category": category,
|
||||
"details": details or {},
|
||||
"timestamp": get_timestamp(),
|
||||
})
|
||||
if len(errors) > 500:
|
||||
errors = errors[-500:]
|
||||
ERROR_LOG_FILE.write_text(json.dumps(errors, indent=2))
|
||||
|
||||
@app.get("/api/errors")
|
||||
def get_errors(limit: int = Query(50, le=200), category: str = ""):
|
||||
if not ERROR_LOG_FILE.exists():
|
||||
return {"errors": []}
|
||||
errors = json.loads(ERROR_LOG_FILE.read_text())
|
||||
if category:
|
||||
errors = [e for e in errors if e.get("category") == category]
|
||||
return {"errors": errors[-limit:]}
|
||||
|
||||
@app.delete("/api/errors")
|
||||
def clear_errors():
|
||||
if ERROR_LOG_FILE.exists():
|
||||
ERROR_LOG_FILE.write_text("[]")
|
||||
return {"status": "cleared"}
|
||||
|
||||
@app.post("/api/errors/report")
|
||||
def report_error(data: dict):
|
||||
log_error(
|
||||
source=data.get("source", "unknown"),
|
||||
message=data.get("message", ""),
|
||||
category=data.get("category", "general"),
|
||||
details=data.get("details"),
|
||||
)
|
||||
return {"status": "reported"}
|
||||
|
||||
# ─── Circuit Breaker (v0.3.0) ──────────────────────────────────────
|
||||
|
||||
CIRCUIT_BREAKER_FILE = BASE_DIR / "data" / "circuit-breaker.json"
|
||||
|
||||
def _get_circuit_state() -> dict:
|
||||
if CIRCUIT_BREAKER_FILE.exists():
|
||||
return json.loads(CIRCUIT_BREAKER_FILE.read_text())
|
||||
return {"agents": {}, "threshold": 3, "recovery_timeout": 300}
|
||||
|
||||
def _save_circuit_state(state: dict):
|
||||
CIRCUIT_BREAKER_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
@app.get("/api/circuit-breaker")
|
||||
def get_circuit_breaker():
|
||||
state = _get_circuit_state()
|
||||
now = time.time()
|
||||
for agent, cb in state.get("agents", {}).items():
|
||||
if cb.get("state") == "open" and now - cb.get("opened_at", 0) > state.get("recovery_timeout", 300):
|
||||
cb["state"] = "half-open"
|
||||
return state
|
||||
|
||||
@app.post("/api/circuit-breaker/trip")
|
||||
def trip_circuit_breaker(data: dict):
|
||||
agent = data.get("agent", "")
|
||||
if agent not in ["opencode", "hermes", "gemini"]:
|
||||
raise HTTPException(400, "Invalid agent")
|
||||
state = _get_circuit_state()
|
||||
if agent not in state["agents"]:
|
||||
state["agents"][agent] = {"state": "closed", "failures": 0, "opened_at": None}
|
||||
cb = state["agents"][agent]
|
||||
cb["failures"] = cb.get("failures", 0) + 1
|
||||
if cb["failures"] >= state["threshold"]:
|
||||
cb["state"] = "open"
|
||||
cb["opened_at"] = time.time()
|
||||
_save_circuit_state(state)
|
||||
append_audit({"action": "circuit_tripped", "agent": agent, "failures": cb["failures"]})
|
||||
return {"agent": agent, "state": cb["state"], "failures": cb["failures"]}
|
||||
|
||||
@app.post("/api/circuit-breaker/reset")
|
||||
def reset_circuit_breaker(data: dict):
|
||||
agent = data.get("agent", "")
|
||||
if agent not in ["opencode", "hermes", "gemini"]:
|
||||
raise HTTPException(400, "Invalid agent")
|
||||
state = _get_circuit_state()
|
||||
state["agents"][agent] = {"state": "closed", "failures": 0, "opened_at": None}
|
||||
_save_circuit_state(state)
|
||||
return {"agent": agent, "state": "closed"}
|
||||
|
||||
# ─── Routes: Standards ────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/standards")
|
||||
|
|
@ -1323,6 +1518,65 @@ def favicon():
|
|||
def favicon_svg():
|
||||
return Response(content=FAVICON_SVG, media_type="image/svg+xml")
|
||||
|
||||
# ─── Scheduler Auto-Start (v0.3.0) ─────────────────────────────────
|
||||
|
||||
_scheduler_instance = None
|
||||
|
||||
@app.on_event("startup")
|
||||
def start_scheduler():
|
||||
global _scheduler_instance
|
||||
try:
|
||||
from scheduler.scheduler import CronScheduler
|
||||
_scheduler_instance = CronScheduler()
|
||||
_scheduler_instance.start()
|
||||
print("Event-driven scheduler started")
|
||||
except Exception as e:
|
||||
print(f"Scheduler not available: {e}")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def stop_scheduler():
|
||||
global _scheduler_instance
|
||||
if _scheduler_instance:
|
||||
try:
|
||||
_scheduler_instance.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ─── PWA Support (v0.3.0) ──────────────────────────────────────────
|
||||
|
||||
MANIFEST_JSON = {
|
||||
"name": "Agentic OS",
|
||||
"short_name": "AgenticOS",
|
||||
"description": "Multi-agent orchestration platform",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f23",
|
||||
"theme_color": "#6c5ce7",
|
||||
"icons": [
|
||||
{"src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"},
|
||||
],
|
||||
}
|
||||
|
||||
@app.get("/manifest.json")
|
||||
def manifest():
|
||||
return JSONResponse(content=MANIFEST_JSON)
|
||||
|
||||
SERVICE_WORKER_JS = """
|
||||
self.addEventListener('install', (e) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(clients.claim());
|
||||
});
|
||||
self.addEventListener('fetch', (e) => {
|
||||
e.respondWith(fetch(e.request).catch(() => new Response('Offline', {status: 503})));
|
||||
});
|
||||
"""
|
||||
|
||||
@app.get("/sw.js")
|
||||
def service_worker():
|
||||
return Response(content=SERVICE_WORKER_JS, media_type="application/javascript")
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Reference in New Issue