From b645625b56969500d17fe6b692efeb9aa9fa2ab3 Mon Sep 17 00:00:00 2001 From: Meleki Date: Tue, 3 Mar 2026 22:44:46 +0100 Subject: [PATCH] Cronjobs + AnthropicService Singleton Refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Endpoints/ChatEndpoint.cs | 13 +- Endpoints/CronjobEndpoint.cs | 183 +++++++++++++++++++++++ Models/CronjobRecord.cs | 23 +++ PLAN-Cronjob.md | 73 +++++++++ Program.cs | 17 ++- Services/CronjobService.cs | 199 +++++++++++++++++++++++++ Services/DatabaseService.cs | 106 +++++++++++++ Services/TelegramService.cs | 15 +- TODO.md | 4 +- gott.csproj | 1 + wwwroot/css/dashboard.css | 14 ++ wwwroot/css/form.css | 63 ++++++++ wwwroot/index.html | 57 +++++++ wwwroot/js/api.js | 55 +++++++ wwwroot/js/pages/cronjob-page.js | 247 +++++++++++++++++++++++++++++++ wwwroot/js/pages/dashboard.js | 90 +++++++++++ 16 files changed, 1132 insertions(+), 28 deletions(-) create mode 100644 Endpoints/CronjobEndpoint.cs create mode 100644 Models/CronjobRecord.cs create mode 100644 PLAN-Cronjob.md create mode 100644 Services/CronjobService.cs create mode 100644 wwwroot/js/pages/cronjob-page.js diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 2c00980..8c3dd28 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -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(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 { diff --git a/Endpoints/CronjobEndpoint.cs b/Endpoints/CronjobEndpoint.cs new file mode 100644 index 0000000..99c624a --- /dev/null +++ b/Endpoints/CronjobEndpoint.cs @@ -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(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(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 }); + } + }); + } +} diff --git a/Models/CronjobRecord.cs b/Models/CronjobRecord.cs new file mode 100644 index 0000000..0ddea23 --- /dev/null +++ b/Models/CronjobRecord.cs @@ -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; } = ""; +} diff --git a/PLAN-Cronjob.md b/PLAN-Cronjob.md new file mode 100644 index 0000000..5446540 --- /dev/null +++ b/PLAN-Cronjob.md @@ -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()` + - 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 diff --git a/Program.cs b/Program.cs index 10a2ac6..dd291ab 100644 --- a/Program.cs +++ b/Program.cs @@ -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(sp => + new AnthropicService( + sp.GetRequiredService(), + anthropicToken, toolService, dbService, memoryService)); + +// Telegram Bot (pro Session – Token aus Session-Config) var maxIterations = builder.Configuration.GetValue("MaxIterations", 100); builder.Services.AddSingleton(sp => new TelegramService( - anthropicToken, sp.GetRequiredService(), - dbService, toolService, memoryService, chatService, + dbService, chatService, + sp.GetRequiredService(), maxIterations)); +// Cronjob-Service (BackgroundService) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + 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"); diff --git a/Services/CronjobService.cs b/Services/CronjobService.cs new file mode 100644 index 0000000..aa03e2d --- /dev/null +++ b/Services/CronjobService.cs @@ -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 _logger; + + public CronjobService( + DatabaseService db, + ChatService chatService, + AnthropicService anthropicService, + TelegramService telegramService, + ILogger 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 { new { type = "text", text = job.Prompt } }; + history.Add(new Dictionary { ["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 + { + new Dictionary + { + ["role"] = "user", + ["content"] = new List { 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 ""; + } + } + + +} diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 402930f..f5aa8ad 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -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 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> GetCronjobs(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync(@" + 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 GetCronjob(string cronjobId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync(@" + 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> GetDueCronjobs() + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + return await conn.QueryAsync(@" + 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 }); + } } diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs index e4cc6be..4eda3f8 100644 --- a/Services/TelegramService.cs +++ b/Services/TelegramService.cs @@ -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"); diff --git a/TODO.md b/TODO.md index 3d112a6..8833435 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/gott.csproj b/gott.csproj index 87ef924..6fe1bb5 100755 --- a/gott.csproj +++ b/gott.csproj @@ -32,6 +32,7 @@ + diff --git a/wwwroot/css/dashboard.css b/wwwroot/css/dashboard.css index 8368a69..7dc57f9 100644 --- a/wwwroot/css/dashboard.css +++ b/wwwroot/css/dashboard.css @@ -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); +} diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index 7360eb5..0565955 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -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); +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 829532b..4d9ed54 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -73,6 +73,13 @@
+
+
+

Cronjobs

+ +
+
+
@@ -208,6 +215,55 @@ + +