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:
1 parent
0120bb8f73
commit
b645625b56
16 files changed
+1132
-28
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user