Der Modell-Refresh war fuer OpenAI faktisch ein No-Op: der Key wurde nur aus
der api_keys-Tabelle geholt, und ohne Key blieb die alte Liste im Speicher
stehen und wurde direkt wieder in models_cache.json geschrieben - waehrend der
Endpoint pauschal Erfolg meldete.
- ServerSettingsService: neues Setting OpenAiApiKey (OpenAI war der einzige
Provider ohne Server-Key, analog Gemini/MiniMax/z.ai)
- OpenAiService: ResolveOpenAiKey() (Setting -> api_keys), HasServerOpenAiKey(),
ResolveProviderConfig zieht den Setting-Key auch im Chat
- OpenAiService: IsOpenAiChatModel() erfasst die o-Reihe inkl. bare IDs
(o1, o3, kuenftig o5) - StartsWith("o3-") hat die verschluckt
- RefreshModelsFromApis() liefert ProviderRefreshResult[] pro Provider;
ModelsEndpoint gibt providers[] zurueck, agent-page.js meldet uebersprungene
Provider als Fehler-Toast statt pauschalem Erfolg
- ChatEndpoint/TelegramService/CronjobService: OpenAI ohne Agent-Key faellt auf
den Server-Key zurueck statt hart abzubrechen
- ARCHITECTURE.md nachgezogen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
14 KiB
C#
311 lines
14 KiB
C#
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 OpenAiService _openAiService;
|
||
private readonly TelegramService _telegramService;
|
||
private readonly ILogger<CronjobService> _logger;
|
||
|
||
public CronjobService(
|
||
DatabaseService db,
|
||
ChatService chatService,
|
||
AnthropicService anthropicService,
|
||
OpenAiService openAiService,
|
||
TelegramService telegramService,
|
||
ILogger<CronjobService> logger)
|
||
{
|
||
_db = db;
|
||
_chatService = chatService;
|
||
_anthropicService = anthropicService;
|
||
_openAiService = openAiService;
|
||
_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");
|
||
|
||
// API-Key des Cronjob-Owners für diesen Agent auflösen
|
||
var userApiKey = await _db.GetAgentApiKeyRecord(job.UserId, ctx.AgentId);
|
||
bool useCliFallback = false;
|
||
|
||
if (userApiKey == null)
|
||
{
|
||
// Kein User-Key – Verhalten exakt wie ChatEndpoint:
|
||
// - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar)
|
||
// - "openai" → harter Fehler (kein Server-Key, kein CLI-Äquivalent)
|
||
// - nvidia/gemini/minimax/pollinations → durchlassen: ResolveProviderConfig löst den Server-Key auf
|
||
if (string.IsNullOrEmpty(ctx.Provider))
|
||
{
|
||
var jobOwner = await _db.GetUser(job.UserId);
|
||
if (jobOwner != null && !jobOwner.CanUseCli && !jobOwner.IsAdmin)
|
||
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen und User {job.UserId} hat keine CLI-Berechtigung.");
|
||
|
||
if (!_anthropicService.IsCliAvailable)
|
||
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen und Claude CLI nicht verfügbar.");
|
||
|
||
useCliFallback = true;
|
||
_logger.LogInformation("Cronjob '{Name}': Kein API-Key für Agent {AgentId}, verwende CLI-Fallback", job.Name, ctx.AgentId);
|
||
}
|
||
else if (ctx.Provider == "openai" && !_openAiService.HasServerOpenAiKey())
|
||
{
|
||
// Ohne Agent-Key greift der Server-Key aus dem Setting 'OpenAiApiKey' (analog Gemini/MiniMax).
|
||
throw new Exception($"Kein OpenAI-Key für Agent {ctx.AgentId} (User {job.UserId}): weder zugewiesen noch als Server-Setting 'OpenAiApiKey' gesetzt.");
|
||
}
|
||
// sonst: nvidia/gemini/minimax/pollinations → ctx.ApiToken bleibt leer,
|
||
// der Server-seitige Provider-Key wird in ResolveProviderConfig aufgelöst.
|
||
}
|
||
else
|
||
{
|
||
ctx.ApiToken = userApiKey.ApiKey;
|
||
}
|
||
|
||
// History aus DB laden. includeThinking=true für OpenAI-kompatible Provider →
|
||
// <think>...</think>-Prefix wird dem Assistant-Text vorangestellt.
|
||
var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
|
||
|
||
// 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 – Provider-Weiche
|
||
string responseText;
|
||
if (!string.IsNullOrEmpty(ctx.Provider))
|
||
responseText = await _openAiService.ChatNonStreaming(ctx, history);
|
||
else if (useCliFallback)
|
||
responseText = await _anthropicService.ChatNonStreamingViaCli(ctx, history);
|
||
else
|
||
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;
|
||
}
|
||
|
||
// Cronjob-Ergebnis als Nachricht in die Notify-Session schreiben (für die Web-History).
|
||
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
|
||
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = notifyCtx.ChainId,
|
||
Iteration = 0,
|
||
Role = "user",
|
||
Type = "text",
|
||
Content = summary
|
||
});
|
||
|
||
await _db.UpdateSessionTimestamp(job.NotifySessionId);
|
||
|
||
// ── Nachricht 1 (Pflicht): das echte Cronjob-Ergebnis ──────────────────────────
|
||
// Geht IMMER raus, unabhängig vom Notify-Agent / API-Key. Früher lief hier nur der
|
||
// Notify-Agent auf der Zusammenfassung; quittierte er bloß mit "👍", erreichte der
|
||
// eigentliche Inhalt Telegram nie. Das Ergebnis ist jetzt die garantierte Nachricht.
|
||
if (!string.IsNullOrEmpty(responseText))
|
||
{
|
||
await _telegramService.SendNotification(job.NotifySessionId,
|
||
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}");
|
||
}
|
||
|
||
// ── Nachricht 2 (optional, best effort): die Reaktion des Notify-Agenten ────────
|
||
// Der Notify-Agent reagiert auf das Ergebnis; seine Antwort geht als SEPARATE
|
||
// Telegram-Nachricht raus und wird (über ChatNonStreaming*) automatisch in die
|
||
// Web-History persistiert. Schlägt der Lauf fehl (kein Key / OpenAI ohne Key /
|
||
// CLI nicht verfügbar), wird die Reaktion still übersprungen – Nachricht 1 ist
|
||
// dann längst zugestellt, es geht also nie wieder Information verloren.
|
||
try
|
||
{
|
||
// API-Key des Cronjob-Owners für den Notify-Agenten auflösen (wie ChatEndpoint):
|
||
// - Anthropic (Provider leer) → Claude-CLI-Fallback (sofern berechtigt/verfügbar)
|
||
// - "openai" → überspringen (kein Server-Key, kein CLI-Äquivalent)
|
||
// - nvidia/gemini/minimax/pollinations → durchlassen: Server-Key via ResolveProviderConfig
|
||
var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId);
|
||
bool notifyUseCliFallback = false;
|
||
bool canRunNotifyAgent = true;
|
||
|
||
if (notifyApiKey == null)
|
||
{
|
||
if (string.IsNullOrEmpty(notifyCtx.Provider))
|
||
{
|
||
var notifyOwner = await _db.GetUser(job.UserId);
|
||
if ((notifyOwner != null && !notifyOwner.CanUseCli && !notifyOwner.IsAdmin) || !_anthropicService.IsCliAvailable)
|
||
{
|
||
_logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: kein API-Key und CLI nicht verfügbar/berechtigt", job.Name);
|
||
canRunNotifyAgent = false;
|
||
}
|
||
else
|
||
{
|
||
notifyUseCliFallback = true;
|
||
}
|
||
}
|
||
else if (notifyCtx.Provider == "openai" && !_openAiService.HasServerOpenAiKey())
|
||
{
|
||
_logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: kein OpenAI-Key (weder zugewiesen noch Server-Setting 'OpenAiApiKey')", job.Name);
|
||
canRunNotifyAgent = false;
|
||
}
|
||
// sonst: nvidia/gemini/minimax/pollinations → notifyCtx.ApiToken bleibt leer (Server-Key-Auflösung).
|
||
}
|
||
else
|
||
{
|
||
notifyCtx.ApiToken = notifyApiKey.ApiKey;
|
||
}
|
||
|
||
if (canRunNotifyAgent)
|
||
{
|
||
// 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 } }
|
||
}
|
||
};
|
||
|
||
string notifyResponse;
|
||
if (!string.IsNullOrEmpty(notifyCtx.Provider))
|
||
notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory);
|
||
else if (notifyUseCliFallback)
|
||
notifyResponse = await _anthropicService.ChatNonStreamingViaCli(notifyCtx, notifyHistory);
|
||
else
|
||
notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
|
||
|
||
if (!string.IsNullOrEmpty(notifyResponse))
|
||
{
|
||
await _telegramService.SendNotification(job.NotifySessionId, $"💬 {notifyResponse}");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception reactEx)
|
||
{
|
||
// Reaktion ist rein optional – das Ergebnis (Nachricht 1) ist bereits zugestellt.
|
||
_logger.LogWarning(reactEx, "Notify-Agent-Reaktion für Cronjob '{Name}' fehlgeschlagen (Ergebnis wurde dennoch gesendet)", job.Name);
|
||
}
|
||
}
|
||
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 "";
|
||
}
|
||
}
|
||
|
||
|
||
}
|