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

+1 -12
View File
@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -22,7 +20,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat", HandleChat);
}
private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, ToolService toolService, CancellationToken cancellationToken)
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken)
{
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null || body.Messages == null || body.Messages.Count == 0)
@@ -32,14 +30,6 @@ public static class ChatEndpoint
return;
}
var token = config["AnthropicToken"];
if (string.IsNullOrEmpty(token))
{
res.StatusCode = 500;
await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" });
return;
}
var sessionId = body.SessionId;
if (string.IsNullOrEmpty(sessionId))
{
@@ -111,7 +101,6 @@ public static class ChatEndpoint
}, attachmentRecords);
}
var anthropic = new AnthropicService(httpFactory, token, toolService, db, memoryService);
streamingTracker.Start(sessionId, ctx.ChainId);
try
{
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class CronjobEndpoint
{
public static void MapCronjobEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/cronjobs", async (HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var cronjobs = await db.GetCronjobs(userId);
return Results.Ok(cronjobs);
});
app.MapGet("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var cronjob = await db.GetCronjob(id);
if (cronjob == null || cronjob.UserId != userId) return Results.NotFound();
return Results.Ok(cronjob);
});
app.MapPost("/api/cronjobs", async (HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var sessionId = body.TryGetProperty("sessionId", out var s) ? s.GetString() ?? "" : "";
var cronExpression = body.TryGetProperty("cronExpression", out var c) ? c.GetString() ?? "" : "";
var prompt = body.TryGetProperty("prompt", out var p) ? p.GetString() ?? "" : "";
var notifySessionId = body.TryGetProperty("notifySessionId", out var ns) ? ns.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(sessionId) ||
string.IsNullOrWhiteSpace(cronExpression) || string.IsNullOrWhiteSpace(prompt))
return Results.BadRequest(new { error = "Name, Session, Cron-Expression und Prompt sind Pflichtfelder" });
// Cron-Expression validieren
var nextRun = CronjobService.CalculateNextRun(cronExpression);
if (string.IsNullOrEmpty(nextRun))
return Results.BadRequest(new { error = "Ungültige Cron-Expression" });
// Session-Zugehörigkeit prüfen
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
return Results.BadRequest(new { error = "Session nicht gefunden" });
// Notify-Session prüfen (optional)
if (!string.IsNullOrEmpty(notifySessionId))
{
var notifySession = await db.GetSession(notifySessionId);
if (notifySession == null || notifySession.UserId != userId)
return Results.BadRequest(new { error = "Benachrichtigungs-Session nicht gefunden" });
}
var cronjob = new CronjobRecord
{
UserId = userId,
SessionId = sessionId,
Name = name,
CronExpression = cronExpression,
Prompt = prompt,
NotifySessionId = notifySessionId,
IsEnabled = true,
NextRunAt = nextRun
};
var created = await db.CreateCronjob(cronjob);
return Results.Ok(created);
});
app.MapPut("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var existing = await db.GetCronjob(id);
if (existing == null || existing.UserId != userId) return Results.NotFound();
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
if (body.TryGetProperty("name", out var n)) existing.Name = n.GetString() ?? existing.Name;
if (body.TryGetProperty("sessionId", out var s))
{
var newSessionId = s.GetString() ?? "";
if (!string.IsNullOrEmpty(newSessionId))
{
var session = await db.GetSession(newSessionId);
if (session == null || session.UserId != userId)
return Results.BadRequest(new { error = "Session nicht gefunden" });
existing.SessionId = newSessionId;
}
}
if (body.TryGetProperty("cronExpression", out var c))
{
var expr = c.GetString() ?? "";
if (!string.IsNullOrEmpty(expr))
{
var nextRun = CronjobService.CalculateNextRun(expr);
if (string.IsNullOrEmpty(nextRun))
return Results.BadRequest(new { error = "Ungültige Cron-Expression" });
existing.CronExpression = expr;
existing.NextRunAt = nextRun;
}
}
if (body.TryGetProperty("prompt", out var p)) existing.Prompt = p.GetString() ?? existing.Prompt;
if (body.TryGetProperty("notifySessionId", out var ns))
{
var notifyId = ns.GetString() ?? "";
if (!string.IsNullOrEmpty(notifyId))
{
var notifySession = await db.GetSession(notifyId);
if (notifySession == null || notifySession.UserId != userId)
return Results.BadRequest(new { error = "Benachrichtigungs-Session nicht gefunden" });
}
existing.NotifySessionId = notifyId;
}
if (body.TryGetProperty("isEnabled", out var e))
{
existing.IsEnabled = e.GetBoolean();
if (existing.IsEnabled)
existing.NextRunAt = CronjobService.CalculateNextRun(existing.CronExpression);
}
await db.UpdateCronjob(existing);
return Results.Ok(existing);
});
app.MapDelete("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var existing = await db.GetCronjob(id);
if (existing == null || existing.UserId != userId) return Results.NotFound();
await db.DeleteCronjob(id, userId);
return Results.Ok();
});
app.MapPost("/api/cronjobs/{id}/toggle", async (string id, HttpContext ctx, DatabaseService db) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var existing = await db.GetCronjob(id);
if (existing == null || existing.UserId != userId) return Results.NotFound();
existing.IsEnabled = !existing.IsEnabled;
if (existing.IsEnabled)
existing.NextRunAt = CronjobService.CalculateNextRun(existing.CronExpression);
await db.UpdateCronjob(existing);
return Results.Ok(existing);
});
app.MapPost("/api/cronjobs/{id}/run", async (string id, HttpContext ctx, DatabaseService db, CronjobService cronjobService) =>
{
var userId = ctx.Items["UserId"]?.ToString();
var existing = await db.GetCronjob(id);
if (existing == null || existing.UserId != userId) return Results.NotFound();
try
{
await cronjobService.ExecuteCronjob(existing);
var nextRun = CronjobService.CalculateNextRun(existing.CronExpression);
await db.UpdateCronjobRun(existing.CronjobId, "success", null, nextRun);
return Results.Ok(new { status = "success" });
}
catch (Exception ex)
{
var nextRun = CronjobService.CalculateNextRun(existing.CronExpression);
await db.UpdateCronjobRun(existing.CronjobId, "error", ex.Message, nextRun);
return Results.Ok(new { status = "error", error = ex.Message });
}
});
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Gott.Models;
public class CronjobRecord
{
public string CronjobId { get; set; } = "";
public string UserId { get; set; } = "";
public string SessionId { get; set; } = "";
public string Name { get; set; } = "";
public string CronExpression { get; set; } = "";
public string Prompt { get; set; } = "";
public string NotifySessionId { get; set; } = "";
public bool IsEnabled { get; set; } = true;
public string LastRunAt { get; set; } = "";
public string NextRunAt { get; set; } = "";
public string LastRunStatus { get; set; } = "";
public string LastRunError { get; set; } = "";
public string CreatedAt { get; set; } = "";
public string UpdatedAt { get; set; } = "";
// Joined from sessions table
public string SessionName { get; set; } = "";
public string NotifySessionName { get; set; } = "";
}
+73
View File
@@ -0,0 +1,73 @@
## Cronjobs – Planungsentwurf
### Konzept: Eigener Worker/Service in der App
**Warum nicht Linux-Cron?**
- App ist self-contained, soll ohne externe Abhängigkeiten laufen
- Cronjobs brauchen Zugriff auf DB, Agents, Sessions, Anthropic API
- Verwaltung über das Frontend (Dashboard) wäre mit Linux-Cron extrem umständlich
- Multi-User: Jeder User hat eigene Cronjobs
### Architektur
**Backend:**
1. **`CronjobRecord`** (Model) – DB-Tabelle `cronjobs`:
- `cronjob_id` (TEXT, GUID)
- `user_id` (TEXT)
- `session_id` (TEXT) – in welcher Session soll der Prompt gesendet werden
- `name` (TEXT) – z.B. "Täglicher Standup-Report"
- `cron_expression` (TEXT) – Standard Cron-Format: `0 9 * * *` (jeden Tag um 9:00)
- `prompt` (TEXT) – die Nachricht die an Claude gesendet wird
- `is_enabled` (BOOL) – aktiv/inaktiv
- `last_run_at` (TEXT) – wann zuletzt ausgeführt
- `next_run_at` (TEXT) – nächste geplante Ausführung (vorberechnet)
- `last_run_status` (TEXT) – "success" / "error"
- `last_run_error` (TEXT) – Fehlermeldung falls error
- `created_at`, `updated_at`
2. **`CronjobService`** (IHostedService / BackgroundService):
- Startet mit der App als `AddHostedService<CronjobService>()`
- Timer prüft jede Minute: "Gibt es Cronjobs deren `next_run_at` <= jetzt?"
- Wenn ja: Prompt an die Session senden (wie ein normaler Chat-Request, aber ohne SSE – fire & forget, Result in DB speichern)
- Nach Ausführung: `last_run_at` updaten, `next_run_at` neu berechnen
3. **`CronjobEndpoint`** (REST API):
- `GET /api/cronjobs` – alle Cronjobs des Users
- `POST /api/cronjobs` – neuen Cronjob anlegen
- `PUT /api/cronjobs/{id}` – bearbeiten
- `DELETE /api/cronjobs/{id}` – löschen
- `POST /api/cronjobs/{id}/toggle` – aktivieren/deaktivieren
- `POST /api/cronjobs/{id}/run` – manuell sofort ausführen (zum Testen)
4. **Cron-Expression Parser**: Nutzen wir `Cronos` NuGet-Package (leichtgewichtig, kein Overhead) oder schreiben wir einen eigenen einfachen Parser?
**Frontend:**
5. **Dashboard**: Neue Section "Cronjobs" mit Cards (ähnlich Sessions/Agents)
6. **Cronjob-Formular** (neue Page `tpl-cronjob`):
- Name
- Session (Dropdown)
- Cron-Expression (Input + Vorschau "Nächste Ausführung: ...")
- Prompt (Textarea)
- Aktiv/Inaktiv Toggle
7. **Cronjob-Card**: Name, Session-Name, Cron-Expression human-readable, Status (aktiv/inaktiv), letzte Ausführung
### Offene Fragen an dich:
1. **Cron-Expression oder vereinfacht?** Soll der User echte Cron-Expressions eingeben (`0 9 * * 1-5`) oder lieber ein vereinfachtes UI (Dropdown: "Täglich um X", "Wöchentlich am Y um Z", etc.)?
2. **Ausführung**: Soll der Cronjob die Antwort von Claude still in die Session-History schreiben (so als hätte der User die Nachricht geschrieben)? Oder soll es einen separaten "Cronjob-Log" geben?
3. **NuGet `Cronos`** für Cron-Parsing verwenden? (Ist der De-facto-Standard in .NET, ~50KB, keine Abhängigkeiten)
4. **Benachrichtigung**: Soll der User benachrichtigt werden wenn ein Cronjob ausgeführt wurde? (z.B. über die StatusBar, oder Telegram?)
--
Antworten auf die offenen Fragen:
1. Cron-Expression
2. Einfach direkt in die Session-History schreiben (als hätte der User die Nachricht geschrieben)
3. Cronos NuGet passt
4. Telegram, falls in der Session, in der der Cronjob läuft, Telegram eingerichtet und verbunden ist
+14 -3
View File
@@ -33,16 +33,26 @@ builder.Services.AddSingleton(toolService);
var chatService = new ChatService(dbService, toolService, memoryService, skillService);
builder.Services.AddSingleton(chatService);
// Telegram Bot (pro Session – Token aus Session-Config)
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
builder.Services.AddSingleton<AnthropicService>(sp =>
new AnthropicService(
sp.GetRequiredService<IHttpClientFactory>(),
anthropicToken, toolService, dbService, memoryService));
// Telegram Bot (pro Session – Token aus Session-Config)
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
builder.Services.AddSingleton<TelegramService>(sp =>
new TelegramService(
anthropicToken,
sp.GetRequiredService<IHttpClientFactory>(),
dbService, toolService, memoryService, chatService,
dbService, chatService,
sp.GetRequiredService<AnthropicService>(),
maxIterations));
// Cronjob-Service (BackgroundService)
builder.Services.AddSingleton<CronjobService>();
builder.Services.AddHostedService<CronjobService>(sp => sp.GetRequiredService<CronjobService>());
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -60,5 +70,6 @@ app.MapSkillEndpoints();
app.MapProfileEndpoints();
app.MapSettingsEndpoints();
app.MapTelegramEndpoints();
app.MapCronjobEndpoints();
app.Run("http://0.0.0.0:19000");
+199
View File
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Cronos;
using Gott.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Gott.Services;
public class CronjobService : BackgroundService
{
private readonly DatabaseService _db;
private readonly ChatService _chatService;
private readonly AnthropicService _anthropicService;
private readonly TelegramService _telegramService;
private readonly ILogger<CronjobService> _logger;
public CronjobService(
DatabaseService db,
ChatService chatService,
AnthropicService anthropicService,
TelegramService telegramService,
ILogger<CronjobService> logger)
{
_db = db;
_chatService = chatService;
_anthropicService = anthropicService;
_telegramService = telegramService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CronjobService gestartet");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessDueCronjobs();
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Verarbeiten der Cronjobs");
}
// Jede Minute prüfen
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task ProcessDueCronjobs()
{
var dueJobs = await _db.GetDueCronjobs();
foreach (var job in dueJobs)
{
_logger.LogInformation("Cronjob '{Name}' ({Id}) wird ausgeführt", job.Name, job.CronjobId);
try
{
await ExecuteCronjob(job);
var nextRun = CalculateNextRun(job.CronExpression);
await _db.UpdateCronjobRun(job.CronjobId, "success", null, nextRun);
_logger.LogInformation("Cronjob '{Name}' erfolgreich. Nächste Ausführung: {Next}", job.Name, nextRun);
}
catch (Exception ex)
{
_logger.LogError(ex, "Cronjob '{Name}' fehlgeschlagen", job.Name);
var nextRun = CalculateNextRun(job.CronExpression);
await _db.UpdateCronjobRun(job.CronjobId, "error", ex.Message, nextRun);
}
}
}
public async Task ExecuteCronjob(CronjobRecord job)
{
// Chat-Context vorbereiten (wie bei normalem Chat)
var ctx = await _chatService.PrepareContext(job.SessionId, job.UserId);
if (ctx == null)
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
// History aus DB laden
var history = await _chatService.BuildHistoryFromDb(job.SessionId);
// User-Nachricht hinzufügen
var userContent = new List<object> { new { type = "text", text = job.Prompt } };
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
// User-Nachricht in DB speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = job.Prompt
});
// Non-Streaming Chat ausführen
var responseText = await _anthropicService.ChatNonStreaming(ctx, history);
// Session-Timestamp aktualisieren
await _db.UpdateSessionTimestamp(job.SessionId);
// Telegram-Benachrichtigung auf der Arbeits-Session senden, falls sie Telegram hat
if (!string.IsNullOrEmpty(responseText))
{
await _telegramService.SendNotification(job.SessionId,
$"⏰ Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}");
}
// Benachrichtigungs-Session: Zusammenfassung in eine andere Session schreiben
if (!string.IsNullOrEmpty(job.NotifySessionId) && job.NotifySessionId != job.SessionId)
{
await SendCronjobNotification(job, responseText);
}
}
private async Task SendCronjobNotification(CronjobRecord job, string responseText)
{
try
{
var notifyCtx = await _chatService.PrepareContext(job.NotifySessionId, job.UserId);
if (notifyCtx == null)
{
_logger.LogWarning("Notification-Session {SessionId} für Cronjob '{Name}' nicht gefunden", job.NotifySessionId, job.Name);
return;
}
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
// In Notify-Session als Nachricht speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = notifyCtx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = summary
});
// Nur die Benachrichtigung als History senden (nicht volle Session-History,
// da diese mit einer assistant-Nachricht enden könnte → prefill-Fehler)
var notifyHistory = new List<object>
{
new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = summary } }
}
};
var notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
await _db.UpdateSessionTimestamp(job.NotifySessionId);
// Telegram auf Notify-Session senden
if (!string.IsNullOrEmpty(notifyResponse))
{
await _telegramService.SendNotification(job.NotifySessionId,
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{notifyResponse}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Senden der Cronjob-Benachrichtigung an Session {SessionId}", job.NotifySessionId);
}
}
private static string Truncate(string text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? "";
return text.Substring(0, maxLength) + "…";
}
public static string CalculateNextRun(string cronExpression)
{
try
{
var cron = CronExpression.Parse(cronExpression, CronFormat.Standard);
// Cron-Expression in Server-Lokalzeit interpretieren
var next = cron.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
return next?.UtcDateTime.ToString("o") ?? "";
}
catch
{
return "";
}
}
}
+106
View File
@@ -166,6 +166,7 @@ public class DatabaseService
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
TryAddColumn(conn, "users", "persona", "TEXT");
TryAddColumn(conn, "users", "settings", "TEXT");
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
@@ -205,6 +206,28 @@ public class DatabaseService
);
CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS cronjobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cronjob_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
prompt TEXT NOT NULL,
notify_session_id TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1,
last_run_at TEXT,
next_run_at TEXT,
last_run_status TEXT,
last_run_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id);
CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at);
");
}
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
@@ -862,4 +885,87 @@ public class DatabaseService
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC",
new { userId });
}
// ── Cronjobs ──
public async Task<CronjobRecord> CreateCronjob(CronjobRecord cronjob)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
cronjob.CronjobId = Guid.NewGuid().ToString("N");
cronjob.CreatedAt = now;
cronjob.UpdatedAt = now;
await conn.ExecuteAsync(@"
INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at)
VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt)
", cronjob);
return cronjob;
}
public async Task<IEnumerable<CronjobRecord>> GetCronjobs(string userId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.user_id = @userId
ORDER BY c.name",
new { userId });
}
public async Task<CronjobRecord> GetCronjob(string cronjobId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.cronjob_id = @cronjobId",
new { cronjobId });
}
public async Task UpdateCronjob(CronjobRecord cronjob)
{
using var conn = CreateConnection();
cronjob.UpdatedAt = DateTime.UtcNow.ToString("o");
await conn.ExecuteAsync(@"
UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression,
prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt
WHERE cronjob_id = @CronjobId AND user_id = @UserId",
cronjob);
}
public async Task DeleteCronjob(string cronjobId, string userId)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId",
new { cronjobId, userId });
}
public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
UPDATE cronjobs SET last_run_at = @now, last_run_status = @status,
last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now
WHERE cronjob_id = @cronjobId",
new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") });
}
public async Task<IEnumerable<CronjobRecord>> GetDueCronjobs()
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
return await conn.QueryAsync<CronjobRecord>(@"
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
FROM cronjobs c
LEFT JOIN sessions s ON c.session_id = s.session_id
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now",
new { now });
}
}
+4 -11
View File
@@ -10,29 +10,23 @@ namespace Gott.Services;
public class TelegramService
{
private readonly string _anthropicToken;
private readonly IHttpClientFactory _httpFactory;
private readonly DatabaseService _db;
private readonly ToolService _toolService;
private readonly MemoryService _memoryService;
private readonly ChatService _chatService;
private readonly AnthropicService _anthropic;
private readonly int _maxIterations;
public TelegramService(
string anthropicToken,
IHttpClientFactory httpFactory,
DatabaseService db,
ToolService toolService,
MemoryService memoryService,
ChatService chatService,
AnthropicService anthropic,
int maxIterations = 100)
{
_anthropicToken = anthropicToken;
_httpFactory = httpFactory;
_db = db;
_toolService = toolService;
_memoryService = memoryService;
_chatService = chatService;
_anthropic = anthropic;
_maxIterations = maxIterations;
}
@@ -165,8 +159,7 @@ public class TelegramService
});
// AnthropicService für Non-Streaming-Chat nutzen
var anthropic = new AnthropicService(_httpFactory, _anthropicToken, _toolService, _db, _memoryService);
var reply = await anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
var reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
{
if (iteration % 3 == 0)
await SendChatAction(chatId, botToken, "typing");
+2 -2
View File
@@ -11,10 +11,10 @@
- [x] Anfrage/Stream abbrechen
- [x] Telegram-Intergration
- [x] Refactoring: Agent/Session-Einstellungen, die den System-Prompt bzw. das Tool Use bearbeiten
- [ ] Cronjobs: Planung, ob cron von Debian/Linux verwendet werden soll, oder ein eigener Worker/Service in der App selbst, der das übernimmt
- [x] Cronjobs: Planung, ob cron von Debian/Linux verwendet werden soll, oder ein eigener Worker/Service in der App selbst, der das übernimmt
- [x] Weiteres Modell hinzufügen: "Haiku 4.5"
- [x] Weitere Tools: Glob? Search? Todo List?
- [ ] Settings in Datenbank speichern (users table) anstelle von localStorage
- [x] Settings in Datenbank speichern (users table) anstelle von localStorage
- [ ] Einzelne Nachicht als Link teilen
## Bugs
+1
View File
@@ -32,6 +32,7 @@
<ItemGroup>
<PackageReference Include="Anthropic" Version="12.8.0" />
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
+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) {