Cronjobs + AnthropicService Singleton Refactoring

Cronjobs:
- CronjobService (BackgroundService) prüft jede Minute auf fällige Jobs
- Cron-Expression in Server-Lokalzeit via Cronos (CronFormat.Standard)
- Cronjob-Ausführung: Prompt → Session → Agent-Antwort in Chat-History
- Optionale Benachrichtigungs-Session (notify_session_id): Zusammenfassung
  wird in separate Session geschrieben (z.B. mit Telegram)
- Telegram-Benachrichtigung mit Cronjob-Name + Ursprungs-Session
- REST API: CRUD + toggle + manuelles Ausführen (/run)
- Frontend: Dashboard-Cards, Cronjob-Formular mit Cron-Vorschau,
  Session-Dropdown, Notify-Session-Dropdown, Status-Box

Refactoring:
- AnthropicService als Singleton in DI registriert
- ChatEndpoint + TelegramService nutzen injizierte Instanz
- Redundante Dependencies (Token, ToolService, MemoryService) entfernt

Neue Dateien:
- Models/CronjobRecord.cs
- Services/CronjobService.cs
- Endpoints/CronjobEndpoint.cs
- wwwroot/js/pages/cronjob-page.js
- PLAN-Cronjob.md
This commit is contained in:
Meleki committed 2026-03-03 22:44:46 +01:00
1 parent 0120bb8f73
commit b645625b56
16 files changed
+1132 -28

No files matched your search

+14
View File
@@ -255,3 +255,17 @@
background: rgb(from var(--text) r g b / .15);
color: var(--text);
}
/* Compact cards (no image) */
.card-compact {
padding: var(--box-padding);
}
.card-compact .card-meta {
flex-direction: row;
flex-wrap: wrap;
}
.card-compact .card-meta-item {
margin-right: var(--box-spacing);
}
+63
View File
@@ -291,3 +291,66 @@ label.skill-item {
.telegram-actions {
margin-top: var(--badge-spacing);
}
/* ── Cronjob Status ── */
.form-status-box {
padding: var(--box-padding);
background: var(--f3-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: var(--badge-spacing);
line-height: 1.6;
}
.form-status-box .status-active {
color: var(--secondary);
}
.form-status-box .status-inactive {
opacity: var(--semi-transparent);
}
.form-status-box .status-error {
color: var(--dangerous);
}
.form-check-group {
display: flex;
align-items: center;
gap: var(--badge-spacing);
}
.form-check-group label {
display: flex;
align-items: center;
gap: var(--badge-spacing);
cursor: pointer;
margin-top: 0;
}
.form-check-group input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 4px;
font-size: var(--font-size);
opacity: var(--semi-transparent);
}
.secondary-btn {
color: var(--secondary);
border-color: var(--secondary);
}
.secondary-btn:hover {
background: var(--secondary);
color: var(--f1-bg);
}
+57
View File
@@ -73,6 +73,13 @@
</div>
<div class="card-grid" id="agentCards"></div>
</section>
<section>
<div class="section-header">
<h2>Cronjobs</h2>
<button id="newCronjobBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Cronjob</button>
</div>
<div class="card-grid" id="cronjobCards"></div>
</section>
</div>
</div>
</template>
@@ -208,6 +215,55 @@
</div>
</template>
<template id="tpl-cronjob">
<div class="page form-page">
<header>
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
<h1 id="cronjobPageTitle">Neuer Cronjob</h1>
</header>
<div class="page-content">
<div class="form-container">
<div class="form-group">
<label for="cronjobName">Name</label>
<input type="text" id="cronjobName" placeholder="z.B. Täglicher Report">
</div>
<div class="form-group">
<label for="cronjobSession">Session</label>
<select id="cronjobSession"></select>
</div>
<div class="form-group">
<label for="cronjobCron">Cron-Expression</label>
<input type="text" id="cronjobCron" placeholder="z.B. 0 9 * * *">
<small id="cronjobCronPreview" class="form-hint"></small>
</div>
<div class="form-group">
<label for="cronjobPrompt">Prompt</label>
<textarea id="cronjobPrompt" rows="6" placeholder="Die Nachricht, die an den Agent gesendet wird..."></textarea>
</div>
<div class="form-group">
<label for="cronjobNotifySession">Benachrichtigungs-Session <small>(optional)</small></label>
<select id="cronjobNotifySession"></select>
<small class="form-hint">Wenn gesetzt, wird eine Zusammenfassung in diese Session geschrieben (z.B. eine mit Telegram).</small>
</div>
<div class="form-group form-check-group">
<label>
<input type="checkbox" id="cronjobEnabled" checked>
Aktiv
</label>
</div>
<div id="cronjobStatusBox" class="form-status-box" hidden></div>
<div class="form-actions">
<button id="cronjobSaveBtn" class="primary-btn"><i class="fa fa-save"></i> Speichern</button>
<button id="cronjobRunBtn" class="secondary-btn" hidden><i class="fa fa-play"></i> Jetzt ausführen</button>
<button id="cronjobDeleteBtn" class="danger-btn" hidden><i class="fa fa-trash"></i> Löschen</button>
</div>
</div>
</div>
</div>
</template>
<template id="tpl-chat">
<div class="page chat-page">
<header>
@@ -390,6 +446,7 @@
<script src="js/pages/chat-page.js"></script>
<script src="js/pages/login-page.js"></script>
<script src="js/pages/settings-page.js"></script>
<script src="js/pages/cronjob-page.js"></script>
<script src="js/pages/profile-page.js"></script>
<script src="js/app.js"></script>
</body>
+55
View File
@@ -321,6 +321,61 @@ async function apiSaveSettings(settings) {
return resp.json();
}
// ── Cronjob API ──
async function apiGetCronjobs() {
const resp = await fetch('/api/cronjobs');
return resp.json();
}
async function apiGetCronjob(id) {
const resp = await fetch(`/api/cronjobs/${id}`);
if (!resp.ok) return null;
return resp.json();
}
async function apiCreateCronjob(data) {
const resp = await fetch('/api/cronjobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Cronjob erstellen fehlgeschlagen');
}
return resp.json();
}
async function apiUpdateCronjob(id, data) {
const resp = await fetch(`/api/cronjobs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Cronjob speichern fehlgeschlagen');
}
return resp.json();
}
async function apiDeleteCronjob(id) {
return fetch(`/api/cronjobs/${id}`, { method: 'DELETE' });
}
async function apiToggleCronjob(id) {
const resp = await fetch(`/api/cronjobs/${id}/toggle`, { method: 'POST' });
return resp.json();
}
async function apiRunCronjob(id) {
const resp = await fetch(`/api/cronjobs/${id}/run`, { method: 'POST' });
return resp.json();
}
// ── Backup API ──
async function apiBackupDb() {
const resp = await fetch('/api/backup', { method: 'POST', credentials: 'include' });
if (!resp.ok) throw new Error('Backup fehlgeschlagen');
+247
View File
@@ -0,0 +1,247 @@
// ── Cronjob Page ──
registerPage('cronjob', {
async init(params) {
const isEdit = params && params.id;
const title = document.getElementById('cronjobPageTitle');
const nameInput = document.getElementById('cronjobName');
const sessionSelect = document.getElementById('cronjobSession');
const cronInput = document.getElementById('cronjobCron');
const cronPreview = document.getElementById('cronjobCronPreview');
const promptInput = document.getElementById('cronjobPrompt');
const notifySelect = document.getElementById('cronjobNotifySession');
const enabledCheckbox = document.getElementById('cronjobEnabled');
const statusBox = document.getElementById('cronjobStatusBox');
const saveBtn = document.getElementById('cronjobSaveBtn');
const runBtn = document.getElementById('cronjobRunBtn');
const deleteBtn = document.getElementById('cronjobDeleteBtn');
// Sessions laden für Dropdown
const sessions = await apiGetSessions();
sessionSelect.innerHTML = '<option value="">– Session wählen –</option>';
notifySelect.innerHTML = '<option value="">– Keine (Antwort bleibt in Arbeits-Session) –</option>';
for (const s of sessions) {
const opt = document.createElement('option');
opt.value = s.sessionId;
opt.textContent = s.name || 'Unbenannt';
if (!s.agentId) {
opt.textContent += ' (kein Agent)';
opt.disabled = true;
}
sessionSelect.appendChild(opt);
// Notify-Dropdown: alle Sessions (auch ohne Agent), Telegram-Sessions markieren
const nOpt = document.createElement('option');
nOpt.value = s.sessionId;
nOpt.textContent = s.name || 'Unbenannt';
if (s.telegramChatId) nOpt.textContent += ' 📱';
notifySelect.appendChild(nOpt);
}
// Cron-Expression Vorschau
cronInput.addEventListener('input', () => updateCronPreview(cronInput, cronPreview));
if (isEdit) {
title.textContent = 'Cronjob bearbeiten';
runBtn.hidden = false;
deleteBtn.hidden = false;
const job = await apiGetCronjob(params.id);
if (!job) {
showToast('Cronjob nicht gefunden', 'error');
navigateTo('dashboard');
return;
}
nameInput.value = job.name || '';
sessionSelect.value = job.sessionId || '';
cronInput.value = job.cronExpression || '';
promptInput.value = job.prompt || '';
notifySelect.value = job.notifySessionId || '';
enabledCheckbox.checked = job.isEnabled;
updateCronPreview(cronInput, cronPreview);
showCronjobStatus(statusBox, job);
// Löschen
deleteBtn.addEventListener('click', async () => {
if (!confirm('Cronjob wirklich löschen?')) return;
await apiDeleteCronjob(params.id);
showToast('Cronjob gelöscht');
navigateTo('dashboard');
});
// Jetzt ausführen
runBtn.addEventListener('click', async () => {
runBtn.disabled = true;
runBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Läuft...';
try {
const result = await apiRunCronjob(params.id);
if (result.status === 'success') {
showToast('Cronjob erfolgreich ausgeführt');
} else {
showToast('Fehler: ' + (result.error || 'Unbekannt'), 'error');
}
// Status neu laden
const updated = await apiGetCronjob(params.id);
if (updated) showCronjobStatus(statusBox, updated);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
} finally {
runBtn.disabled = false;
runBtn.innerHTML = '<i class="fa fa-play"></i> Jetzt ausführen';
}
});
}
// Speichern
saveBtn.addEventListener('click', async () => {
const data = {
name: nameInput.value.trim(),
sessionId: sessionSelect.value,
cronExpression: cronInput.value.trim(),
prompt: promptInput.value.trim(),
notifySessionId: notifySelect.value,
isEnabled: enabledCheckbox.checked
};
if (!data.name || !data.sessionId || !data.cronExpression || !data.prompt) {
showToast('Bitte alle Felder ausfüllen', 'error');
return;
}
saveBtn.disabled = true;
try {
if (isEdit) {
await apiUpdateCronjob(params.id, data);
showToast('Cronjob gespeichert');
const updated = await apiGetCronjob(params.id);
if (updated) showCronjobStatus(statusBox, updated);
} else {
const created = await apiCreateCronjob(data);
showToast('Cronjob erstellt');
navigateTo('cronjob', { id: created.cronjobId });
}
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
} finally {
saveBtn.disabled = false;
}
});
}
});
function updateCronPreview(input, preview) {
const expr = input.value.trim();
if (!expr) {
preview.textContent = '';
return;
}
const desc = describeCron(expr);
if (desc) {
preview.textContent = desc;
preview.style.color = '';
} else {
preview.textContent = 'Ungültige Cron-Expression';
preview.style.color = 'var(--color-error)';
}
}
function describeCron(expr) {
try {
const parts = expr.split(/\s+/);
if (parts.length < 5 || parts.length > 5) return null;
const [min, hour, dom, month, dow] = parts;
// Einfache Fälle beschreiben
const dowNames = { '0': 'So', '1': 'Mo', '2': 'Di', '3': 'Mi', '4': 'Do', '5': 'Fr', '6': 'Sa', '7': 'So' };
let desc = '';
const tz = 'Europe/Berlin';
if (dom === '*' && month === '*' && dow === '*') {
if (min !== '*' && hour !== '*') {
desc = `Täglich um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
} else if (hour === '*' && min === '0') {
desc = 'Jede Stunde';
} else if (hour === '*' && min === '*') {
desc = 'Jede Minute';
} else if (hour !== '*') {
desc = `Täglich, stündlich um ${hour} Uhr`;
}
} else if (dom === '*' && month === '*' && dow !== '*') {
// Ranges wie 1-5 auflösen
let dayParts = [];
for (const part of dow.split(',')) {
if (part.includes('-')) {
const [from, to] = part.split('-');
dayParts.push(`${dowNames[from] || from}–${dowNames[to] || to}`);
} else {
dayParts.push(dowNames[part] || part);
}
}
const days = dayParts.join(', ');
if (min !== '*' && hour !== '*') {
desc = `${days} um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
} else {
desc = `An: ${days}`;
}
} else if (dow === '*' && month === '*' && dom !== '*') {
desc = `Am ${dom}. jedes Monats`;
if (min !== '*' && hour !== '*') {
desc += ` um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
}
}
if (!desc) {
desc = `${expr} (5-Feld Cron)`;
}
return desc;
} catch {
return null;
}
}
function showCronjobStatus(box, job) {
box.hidden = false;
const parts = [];
if (job.isEnabled) {
parts.push('<span class="status-active"><i class="fa fa-check-circle"></i> Aktiv</span>');
} else {
parts.push('<span class="status-inactive"><i class="fa fa-pause-circle"></i> Pausiert</span>');
}
if (job.nextRunAt) {
const next = new Date(job.nextRunAt);
parts.push(`<span><i class="fa fa-clock-o"></i> Nächste Ausführung: ${next.toLocaleString('de-AT')}</span>`);
}
if (job.lastRunAt) {
const last = new Date(job.lastRunAt);
const statusIcon = job.lastRunStatus === 'success' ? '✅' : '❌';
parts.push(`<span>${statusIcon} Letzte Ausführung: ${last.toLocaleString('de-AT')}</span>`);
if (job.lastRunStatus === 'error' && job.lastRunError) {
parts.push(`<span class="status-error"><i class="fa fa-exclamation-triangle"></i> ${escapeHtml(job.lastRunError)}</span>`);
}
}
if (job.sessionName) {
parts.push(`<span><i class="fa fa-comment"></i> Session: ${escapeHtml(job.sessionName)}</span>`);
}
if (job.notifySessionName) {
parts.push(`<span><i class="fa fa-bell"></i> Benachrichtigung → ${escapeHtml(job.notifySessionName)}</span>`);
}
box.innerHTML = parts.join('<br>');
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
+90
View File
@@ -235,6 +235,96 @@ registerPage('dashboard', {
// Neuer Agent Button
document.getElementById('newAgentBtn').addEventListener('click', () => navigateTo('agent'));
// Cronjobs
const cronjobs = await apiGetCronjobs();
const cronjobGrid = document.getElementById('cronjobCards');
cronjobGrid.innerHTML = '';
for (const cj of cronjobs) {
const card = document.createElement('div');
card.className = 'card card-compact';
const statusBadge = cj.isEnabled
? '<span class="badge badge-feature"><i class="fa fa-check-circle"></i> Aktiv</span>'
: '<span class="badge badge-off"><i class="fa fa-pause-circle"></i> Pausiert</span>';
const lastRunIcon = cj.lastRunStatus === 'success' ? '✅'
: cj.lastRunStatus === 'error' ? '❌' : '';
const cronDesc = describeCron(cj.cronExpression) || cj.cronExpression;
card.innerHTML = `
<div class="card-body">
<div class="card-title-row">
<span class="card-title">${esc(cj.name)}</span>
${statusBadge}
<div class="card-menu">
<button class="card-menu-trigger icon-btn"><i class="fa fa-ellipsis-v"></i></button>
<div class="card-menu-dropdown" hidden>
<button class="card-menu-item" data-action="edit"><i class="fa fa-pencil"></i> Bearbeiten</button>
<button class="card-menu-item" data-action="toggle"><i class="fa fa-${cj.isEnabled ? 'pause' : 'play'}"></i> ${cj.isEnabled ? 'Pausieren' : 'Aktivieren'}</button>
<button class="card-menu-item" data-action="run"><i class="fa fa-play-circle"></i> Jetzt ausführen</button>
</div>
</div>
</div>
<div class="card-meta">
<span class="card-meta-item"><i class="fa fa-clock-o"></i> ${esc(cronDesc)}</span>
${cj.sessionName ? `<span class="card-meta-item"><i class="fa fa-comment"></i> ${esc(cj.sessionName)}</span>` : ''}
${lastRunIcon ? `<span class="card-meta-item">${lastRunIcon} ${cj.lastRunAt ? formatDate(cj.lastRunAt) : ''}</span>` : ''}
</div>
</div>
`;
const menuTrigger = card.querySelector('.card-menu-trigger');
const menuDropdown = card.querySelector('.card-menu-dropdown');
menuTrigger.addEventListener('click', (e) => {
e.stopPropagation();
closeAllCardMenus();
const rect = menuTrigger.getBoundingClientRect();
menuDropdown.hidden = false;
const dropRect = menuDropdown.getBoundingClientRect();
menuDropdown.style.top = `${rect.bottom + 4}px`;
menuDropdown.style.left = `${rect.right - dropRect.width}px`;
if (rect.bottom + 4 + dropRect.height > window.innerHeight) {
menuDropdown.style.top = `${rect.top - dropRect.height - 4}px`;
}
const finalRect = menuDropdown.getBoundingClientRect();
if (finalRect.left < 0) menuDropdown.style.left = '8px';
});
menuDropdown.querySelector('[data-action="edit"]').addEventListener('click', (e) => {
e.stopPropagation();
menuDropdown.hidden = true;
navigateTo('cronjob', { id: cj.cronjobId });
});
menuDropdown.querySelector('[data-action="toggle"]').addEventListener('click', async (e) => {
e.stopPropagation();
menuDropdown.hidden = true;
await apiToggleCronjob(cj.cronjobId);
showToast(cj.isEnabled ? 'Cronjob pausiert' : 'Cronjob aktiviert');
navigateTo('dashboard', {}, { replace: true });
});
menuDropdown.querySelector('[data-action="run"]').addEventListener('click', async (e) => {
e.stopPropagation();
menuDropdown.hidden = true;
showToast('Cronjob wird ausgeführt...');
const result = await apiRunCronjob(cj.cronjobId);
if (result.status === 'success') {
showToast('Cronjob erfolgreich ausgeführt');
} else {
showToast('Fehler: ' + (result.error || 'Unbekannt'), 'error');
}
navigateTo('dashboard', {}, { replace: true });
});
cronjobGrid.appendChild(card);
}
// Neuer Cronjob Button
document.getElementById('newCronjobBtn').addEventListener('click', () => navigateTo('cronjob'));
},
destroy() {
if (this._closeCardMenus) {