feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.AgentComm;
|
||||
using ClawdDotNet.Tools.AgentEditor;
|
||||
using ClawdDotNet.Tools.AgentSpawn;
|
||||
using ClawdDotNet.Tools.Database;
|
||||
using ClawdDotNet.Tools.DirectAPI;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using ClawdDotNet.Tools.FTP;
|
||||
using ClawdDotNet.Tools.Mail;
|
||||
using ClawdDotNet.Tools.SocialMediaManager;
|
||||
using ClawdDotNet.Tools.Telegram;
|
||||
using ClawdDotNet.Tools.TelegramClient;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using ClawdDotNet.Tools.WebMonitor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App;
|
||||
|
||||
/// <summary>
|
||||
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
|
||||
/// Oberflächencode.
|
||||
///
|
||||
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
|
||||
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
|
||||
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
|
||||
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
|
||||
///
|
||||
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
|
||||
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
|
||||
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
|
||||
/// festgeschrieben zu sein.</para>
|
||||
/// </summary>
|
||||
public sealed class AppHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<Func<ValueTask>> _shutdown = [];
|
||||
|
||||
public required SettingsManager Settings { get; init; }
|
||||
public required InstanceDirectoryManager Directories { get; init; }
|
||||
public required InstanceConfig Instance { get; init; }
|
||||
public required string InstancePath { get; init; }
|
||||
public required string LogDirectory { get; init; }
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||||
public AgentEngine? Engine { get; private init; }
|
||||
|
||||
public TaskScanner? Scanner { get; private init; }
|
||||
public StagingService? Staging { get; private init; }
|
||||
public SqliteUsageRepository? Usage { get; private init; }
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
|
||||
/// Null, wenn Adresse oder Token fehlen.
|
||||
/// </summary>
|
||||
public DeploymentcenterService? Deploymentcenter { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
|
||||
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
|
||||
/// </summary>
|
||||
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
|
||||
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
|
||||
/// </summary>
|
||||
public LicenseGate? License { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
|
||||
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
|
||||
/// </summary>
|
||||
public LicenseWatch? LicenseWatch { get; private set; }
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
/// <summary>
|
||||
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
|
||||
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
|
||||
/// </summary>
|
||||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||||
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht.</summary>
|
||||
public required ILicensePrompt License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
|
||||
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
|
||||
/// Dienst dauerhaft blockieren würde.
|
||||
/// </summary>
|
||||
public Func<string, Task<string>>? TelegramLogin { get; init; }
|
||||
|
||||
public Func<Task<string>>? Telegram2FA { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
|
||||
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
|
||||
/// nachzureichen: Die hat der Torwächter schon gezeigt.
|
||||
/// </summary>
|
||||
public readonly record struct StartupResult(AppHost? Host, string? Error);
|
||||
|
||||
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
|
||||
{
|
||||
// ─── 1. Anwendungseinstellungen ───
|
||||
var settings = new SettingsManager();
|
||||
settings.Load();
|
||||
|
||||
// ─── 2. Instanz wählen ───
|
||||
var directories = new InstanceDirectoryManager(
|
||||
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
|
||||
|
||||
var instancePath = await callbacks.SelectInstance(directories);
|
||||
if (string.IsNullOrWhiteSpace(instancePath))
|
||||
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
|
||||
|
||||
InstanceConfig instance;
|
||||
try
|
||||
{
|
||||
instance = directories.LoadInstanceConfig(instancePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new StartupResult(null,
|
||||
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
|
||||
}
|
||||
|
||||
// ─── 3. Protokollierung ───
|
||||
var logDirectory = Path.GetFullPath(
|
||||
!string.IsNullOrWhiteSpace(instance.LogDirectory)
|
||||
? instance.LogDirectory
|
||||
: settings.AppSettings.LogDirectory);
|
||||
|
||||
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
|
||||
settings.AppSettings.MinimumLogLevel, true, out var parsed)
|
||||
? parsed
|
||||
: Core.Logging.LogLevel.Info;
|
||||
|
||||
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
|
||||
|
||||
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
|
||||
instance.InstanceName, instance.InstanceId);
|
||||
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||||
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
|
||||
|
||||
// ─── 4. Lizenz ───
|
||||
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
|
||||
callbacks.License);
|
||||
|
||||
if (!await license.RunStartupCheckAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
|
||||
loggerFactory.Dispose();
|
||||
return new StartupResult(null, null);
|
||||
}
|
||||
|
||||
// ─── 5. Werkzeuge ───
|
||||
var tools = RegisterTools();
|
||||
|
||||
TelegramClientManager? telegram = null;
|
||||
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
|
||||
{
|
||||
telegram = new TelegramClientManager(instance, instancePath,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
|
||||
|
||||
tools.Register(new TelegramClientTool(telegram));
|
||||
logger.LogInformation("TelegramClient-Tool registriert");
|
||||
}
|
||||
else if (instance.TelegramClient is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
|
||||
}
|
||||
|
||||
var host = BuildCore(settings, directories, instance, instancePath,
|
||||
logDirectory, loggerFactory, tools, telegram, logger);
|
||||
|
||||
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
|
||||
host.License = license;
|
||||
|
||||
if (license.IsEnforcementConfigured)
|
||||
{
|
||||
host.LicenseWatch = new LicenseWatch(
|
||||
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
|
||||
|
||||
host.LicenseWatch.Start();
|
||||
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
|
||||
}
|
||||
|
||||
await host.ConnectTelegramAsync(callbacks, logger);
|
||||
await host.StartDeploymentcenterAsync(loggerFactory, ct);
|
||||
|
||||
return new StartupResult(host, null);
|
||||
}
|
||||
|
||||
// ─── Aufbau ───
|
||||
|
||||
private static ToolRegistry RegisterTools()
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
registry.Register(new FileRWTool());
|
||||
registry.Register(new TelegramTool());
|
||||
registry.Register(new MailTool());
|
||||
registry.Register(new DatabaseTool());
|
||||
registry.Register(new FTPTool());
|
||||
registry.Register(new DirectApiTool());
|
||||
registry.Register(new WebFetchTool());
|
||||
registry.Register(new WebMonitorTool());
|
||||
registry.Register(new AgentCommTool());
|
||||
registry.Register(new SocialMediaManagerTool());
|
||||
registry.Register(new AgentSpawnTool());
|
||||
registry.Register(new AgentEditorTool());
|
||||
registry.Register(new Tools.Memory.MemoryTool());
|
||||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static AppHost BuildCore(
|
||||
SettingsManager settings,
|
||||
InstanceDirectoryManager directories,
|
||||
InstanceConfig instance,
|
||||
string instancePath,
|
||||
string logDirectory,
|
||||
ILoggerFactory loggerFactory,
|
||||
ToolRegistry tools,
|
||||
TelegramClientManager? telegram,
|
||||
ILogger logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
|
||||
{
|
||||
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
|
||||
|
||||
return new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
TelegramClient = telegram
|
||||
};
|
||||
}
|
||||
|
||||
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
|
||||
|
||||
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
|
||||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||||
var stateStore = new SqliteStateStore(storage);
|
||||
var memory = new SqliteMemoryRepository(storage);
|
||||
var taskRepository = new SqliteTaskRepository(storage);
|
||||
var audit = new SqliteAuditRepository(storage);
|
||||
var stagingRepository = new SqliteStagingRepository(storage);
|
||||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
|
||||
var usage = new SqliteUsageRepository(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricing = new ModelPricingCatalog();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
|
||||
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
|
||||
});
|
||||
|
||||
var engine = new AgentEngine(
|
||||
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
|
||||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||||
{
|
||||
InstanceBudget = instance.Budget
|
||||
};
|
||||
|
||||
engine.SetAgentConfigProvider(
|
||||
() => instance.Agents,
|
||||
instance.InstanceId,
|
||||
agentId =>
|
||||
{
|
||||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging) = BuildTaskboard(
|
||||
instance, taskRepository, engine, tools, stateStore, audit,
|
||||
stagingRepository, loggerFactory, logger);
|
||||
|
||||
var host = new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
Engine = engine,
|
||||
Scanner = scanner,
|
||||
Staging = staging,
|
||||
Usage = usage,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
|
||||
};
|
||||
|
||||
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
|
||||
|
||||
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||||
/// </summary>
|
||||
private static (TaskScanner?, StagingService?) BuildTaskboard(
|
||||
InstanceConfig instance,
|
||||
SqliteTaskRepository taskRepository,
|
||||
AgentEngine engine,
|
||||
ToolRegistry tools,
|
||||
SqliteStateStore stateStore,
|
||||
SqliteAuditRepository audit,
|
||||
SqliteStagingRepository stagingRepository,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
var sharedWorkspace = instance.Agents
|
||||
.Select(a => a.SharedWorkspacePath)
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
return (null, null);
|
||||
|
||||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
|
||||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
var coordination = new CoordinationMigration(
|
||||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||||
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
|
||||
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
|
||||
|
||||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||||
|
||||
logger.LogInformation(
|
||||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
|
||||
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||||
imported, migratedCoordination, migratedScheduler, reset);
|
||||
|
||||
scanner.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||||
}
|
||||
});
|
||||
|
||||
return (scanner, staging);
|
||||
}
|
||||
|
||||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||||
{
|
||||
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
|
||||
|
||||
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
|
||||
|
||||
if (callbacks.Telegram2FA is not null)
|
||||
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
|
||||
|
||||
try
|
||||
{
|
||||
await TelegramClient.ConnectAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
|
||||
/// die einmalige Update-Prüfung.
|
||||
///
|
||||
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
|
||||
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
|
||||
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
|
||||
/// </summary>
|
||||
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
|
||||
{
|
||||
Deploymentcenter = DeploymentcenterService.TryCreate(
|
||||
Settings.AppSettings, AppVersion, loggerFactory);
|
||||
|
||||
if (Deploymentcenter is null)
|
||||
return;
|
||||
|
||||
_shutdown.Add(Deploymentcenter.DisposeAsync);
|
||||
|
||||
var health = new InstanceHealthProvider(
|
||||
Instance.InstanceName,
|
||||
agentsEnabled: Engine is not null,
|
||||
Instance.Budget,
|
||||
Usage,
|
||||
() => Instance.Agents.Count,
|
||||
() => Engine?.RunningChatCount ?? 0,
|
||||
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
|
||||
// Startabgleichung los, der erste Heartbeat geht sofort raus.
|
||||
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
|
||||
|
||||
await Deploymentcenter.StartWatchdogAsync(
|
||||
Instance, health,
|
||||
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
|
||||
ct);
|
||||
|
||||
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
|
||||
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
|
||||
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c><Version></c> in
|
||||
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
|
||||
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
|
||||
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
|
||||
/// </summary>
|
||||
public static string AppVersion => ReleaseInfo.Version;
|
||||
|
||||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||||
public static string BuildSummary => ReleaseInfo.Summary;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var step in _shutdown)
|
||||
{
|
||||
try { await step(); }
|
||||
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||||
}
|
||||
|
||||
if (Status is not null) await Status.DisposeAsync();
|
||||
if (Scanner is not null) await Scanner.DisposeAsync();
|
||||
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
|
||||
|
||||
LoggerFactory.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.App</RootNamespace>
|
||||
|
||||
<!-- Erzeugt ClawdDotNet.App.ReleaseInfo (Version, Git-Commit, Build-Datum, Kanal).
|
||||
Bewusst nicht "BuildInfo": Diesen Namen traegt in ClawdDotNet.Core schon ein von
|
||||
Hand gefuehrter Zaehler mit Aenderungstext. Zwei gleichnamige Klassen mit
|
||||
verschiedener Bedeutung waeren eine Falle. -->
|
||||
<DeploymentcenterBuildInfoClass>ReleaseInfo</DeploymentcenterBuildInfoClass>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Version, Commit und Build-Datum zur Uebersetzungszeit einbetten. Vorher wurde die
|
||||
Version an drei Stellen erraten: fest "1.0.0" im SDK, "0.0.<Build>" fuer den
|
||||
Versionsvergleich und nichts am Heartbeat. -->
|
||||
<Import Project="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||
|
||||
<!-- Bewusst ohne Oberflaechen-Abhaengigkeit: Auf dieser Schicht setzen sowohl die
|
||||
Avalonia-Anwendung als auch der spaetere kopflose Host auf. Wer hier einen
|
||||
Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebMonitor\ClawdDotNet.Tools.WebMonitor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentComm\ClawdDotNet.Tools.AgentComm.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentSpawn\ClawdDotNet.Tools.AgentSpawn.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
|
||||
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
|
||||
Fehler-Stream und Bugtracker deckt es nicht ab — die stehen in
|
||||
ClawdDotNet.Core/Deploymentcenter. Cross-Repo-Pfad; langfristig als Git-Submodul
|
||||
unter external/ ablegen. -->
|
||||
<ProjectReference Include="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Eintrag in der AgentList.json – Basisinformationen zu einem Agenten.
|
||||
/// Liegt im Agents/-Ordner einer Instanz.
|
||||
/// </summary>
|
||||
public sealed class AgentListItem
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("folderName")]
|
||||
public string FolderName { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root-Objekt der AgentList.json
|
||||
/// </summary>
|
||||
public sealed class AgentListFile
|
||||
{
|
||||
[JsonPropertyName("agents")]
|
||||
public List<AgentListItem> Agents { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
|
||||
/// </summary>
|
||||
public sealed class InstanceInfo
|
||||
{
|
||||
public string InstanceName { get; set; } = "";
|
||||
public string FolderName { get; set; } = "";
|
||||
public string FolderPath { get; set; } = "";
|
||||
public int AgentCount { get; set; }
|
||||
public string ApiKeyStatus { get; set; } = "—";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
public sealed class JobHistoryEntry
|
||||
{
|
||||
[JsonPropertyName("jobName")]
|
||||
public string JobName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("agent")]
|
||||
public string Agent { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("time")]
|
||||
public DateTime Time { get; set; } = DateTime.Now;
|
||||
|
||||
[JsonPropertyName("jobDescription")]
|
||||
public string JobDescription { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public string Info { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "Success"; // Success, Error, Manual
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
public sealed class TokenUsageRecord
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
|
||||
[JsonPropertyName("agentId")]
|
||||
public string AgentId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("agentName")]
|
||||
public string AgentName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("promptTokens")]
|
||||
public int PromptTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("completionTokens")]
|
||||
public int CompletionTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("totalTokens")]
|
||||
public int TotalTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("costUsd")]
|
||||
public double CostUsd { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("stepCount")]
|
||||
public int StepCount { get; set; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TokenUsageFile
|
||||
{
|
||||
[JsonPropertyName("instanceId")]
|
||||
public string InstanceId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("instanceName")]
|
||||
public string InstanceName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("records")]
|
||||
public List<TokenUsageRecord> Records { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
|
||||
static class ConfigHelper
|
||||
{
|
||||
public static string GetString(Dictionary<string, object?> config, string key, string fallback = "")
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? fallback,
|
||||
JsonElement je => je.ToString(),
|
||||
string s => s,
|
||||
null => fallback,
|
||||
_ => val.ToString() ?? fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetInt(Dictionary<string, object?> config, string key, int fallback = 0)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
JsonElement je => int.TryParse(je.ToString(), out var r) ? r : fallback,
|
||||
int i => i,
|
||||
_ => int.TryParse(val?.ToString(), out var r) ? r : fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static bool GetBool(Dictionary<string, object?> config, string key, bool fallback = false)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind is JsonValueKind.True => true,
|
||||
JsonElement je when je.ValueKind is JsonValueKind.False => false,
|
||||
JsonElement je => bool.TryParse(je.ToString(), out var r) ? r : fallback,
|
||||
bool b => b,
|
||||
_ => bool.TryParse(val?.ToString(), out var r) ? r : fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetStringArray(Dictionary<string, object?> config, string key)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Array =>
|
||||
string.Join(",", je.EnumerateArray().Select(e => e.GetString())),
|
||||
object[] arr => string.Join(",", arr),
|
||||
string s => s,
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum FileRWAccessLevel
|
||||
{
|
||||
Denied,
|
||||
Read,
|
||||
ReadWrite,
|
||||
Admin
|
||||
}
|
||||
|
||||
public sealed class FileRWToolSettings
|
||||
{
|
||||
[Category("Persönlicher Workspace")]
|
||||
[DisplayName("Erlaubte Endungen")]
|
||||
[Description("Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)")]
|
||||
public string PersonalAllowedExtensions { get; set; } = ".txt,.json,.md,.html,.js,.css";
|
||||
|
||||
[Category("Shared Workspace")]
|
||||
[DisplayName("Zugriffslevel")]
|
||||
[Description("Legt fest, welche Operationen im SharedWorkspace erlaubt sind")]
|
||||
public FileRWAccessLevel SharedAccessLevel { get; set; } = FileRWAccessLevel.Denied;
|
||||
|
||||
[Category("Shared Workspace")]
|
||||
[DisplayName("Erlaubte Endungen")]
|
||||
[Description("Dateiendungen für den geteilten Workspace")]
|
||||
public string SharedAllowedExtensions { get; set; } = ".txt,.json,.md";
|
||||
|
||||
[Category("Shared Workspace – Schutz")]
|
||||
[DisplayName("Geschützte Pfade")]
|
||||
[Description("Komma-getrennte Pfade im SharedWorkspace die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.")]
|
||||
public string ProtectedPaths { get; set; } = "stocks/";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["personalAllowedExtensions"] = PersonalAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["sharedAccessLevel"] = SharedAccessLevel.ToString(),
|
||||
["sharedAllowedExtensions"] = SharedAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["protectedPaths"] = ProtectedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static FileRWToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
PersonalAllowedExtensions = ConfigHelper.GetStringArray(config, "personalAllowedExtensions") is { Length: > 0 } s1
|
||||
? s1 : (ConfigHelper.GetStringArray(config, "allowedExtensions") is { Length: > 0 } sOld ? sOld : ".txt,.json,.md,.html,.js,.css"),
|
||||
|
||||
SharedAccessLevel = Enum.TryParse<FileRWAccessLevel>(ConfigHelper.GetString(config, "sharedAccessLevel"), true, out var level)
|
||||
? level : FileRWAccessLevel.Denied,
|
||||
|
||||
SharedAllowedExtensions = ConfigHelper.GetStringArray(config, "sharedAllowedExtensions") is { Length: > 0 } s2
|
||||
? s2 : ".txt,.json,.md",
|
||||
|
||||
ProtectedPaths = ConfigHelper.GetStringArray(config, "protectedPaths") is { Length: > 0 } s3
|
||||
? s3 : "stocks/"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class MailToolSettings
|
||||
{
|
||||
[Category("Mail - Konto")]
|
||||
[DisplayName("Benutzername")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Category("Mail - Konto")]
|
||||
[DisplayName("Passwort")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Category("Mail - IMAP")]
|
||||
[DisplayName("IMAP-Host")]
|
||||
public string ImapHost { get; set; } = "";
|
||||
|
||||
[Category("Mail - IMAP")]
|
||||
[DisplayName("IMAP-Port")]
|
||||
public int ImapPort { get; set; } = 993;
|
||||
|
||||
[Category("Mail - SMTP")]
|
||||
[DisplayName("SMTP-Host")]
|
||||
public string SmtpHost { get; set; } = "";
|
||||
|
||||
[Category("Mail - SMTP")]
|
||||
[DisplayName("SMTP-Port")]
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
|
||||
[Category("Mail - Sicherheit")]
|
||||
[DisplayName("Erlaubte Empfänger")]
|
||||
[Description("Komma-getrennte Liste erlaubter E-Mail-Adressen")]
|
||||
public string AllowedRecipients { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["username"] = Username,
|
||||
["password"] = Password,
|
||||
["imapHost"] = ImapHost,
|
||||
["imapPort"] = ImapPort,
|
||||
["smtpHost"] = SmtpHost,
|
||||
["smtpPort"] = SmtpPort,
|
||||
["allowedRecipients"] = AllowedRecipients.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static MailToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Username = ConfigHelper.GetString(config, "username"),
|
||||
Password = ConfigHelper.GetString(config, "password"),
|
||||
ImapHost = ConfigHelper.GetString(config, "imapHost"),
|
||||
ImapPort = ConfigHelper.GetInt(config, "imapPort", 993),
|
||||
SmtpHost = ConfigHelper.GetString(config, "smtpHost"),
|
||||
SmtpPort = ConfigHelper.GetInt(config, "smtpPort", 587),
|
||||
AllowedRecipients = ConfigHelper.GetStringArray(config, "allowedRecipients")
|
||||
};
|
||||
}
|
||||
|
||||
public enum DatabaseType
|
||||
{
|
||||
MySql,
|
||||
Postgres,
|
||||
MsSql,
|
||||
MongoDb
|
||||
}
|
||||
|
||||
public enum DatabaseAccessLevel
|
||||
{
|
||||
[Description("Nur Lesen (SELECT/find)")]
|
||||
ReadOnly,
|
||||
[Description("Lesen und Schreiben (INSERT/UPDATE/DELETE)")]
|
||||
ReadWrite,
|
||||
[Description("Vollzugriff (Admin/Schema-Änderungen)")]
|
||||
Admin
|
||||
}
|
||||
|
||||
public sealed class DatabaseToolSettings
|
||||
{
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Typ")]
|
||||
[Description("Der zu verwendende Datenbanktyp")]
|
||||
public DatabaseType Type { get; set; } = DatabaseType.MySql;
|
||||
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Connection-String")]
|
||||
public string ConnectionString { get; set; } = "";
|
||||
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Zugriffsebene")]
|
||||
[Description("Legt fest, welche Operationen der Agent ausführen darf")]
|
||||
public DatabaseAccessLevel AccessLevel { get; set; } = DatabaseAccessLevel.ReadOnly;
|
||||
|
||||
[Category("Datenbank - Sicherheit")]
|
||||
[DisplayName("Erlaubte Tabellen")]
|
||||
[Description("Komma-getrennte Liste erlaubter Tabellen/Collections")]
|
||||
public string AllowedTables { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["type"] = Type.ToString().ToLowerInvariant(),
|
||||
["connectionString"] = ConnectionString,
|
||||
["accessLevel"] = AccessLevel.ToString(),
|
||||
["allowedTables"] = AllowedTables.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static DatabaseToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Type = Enum.TryParse<DatabaseType>(config.GetValueOrDefault("type")?.ToString(), true, out var result) ? result : DatabaseType.MySql,
|
||||
ConnectionString = config.GetValueOrDefault("connectionString")?.ToString() ?? "",
|
||||
AccessLevel = Enum.TryParse<DatabaseAccessLevel>(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level)
|
||||
? level
|
||||
: (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly),
|
||||
AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr
|
||||
? string.Join(",", arr)
|
||||
: config.GetValueOrDefault("allowedTables")?.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class FTPToolSettings
|
||||
{
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Port")]
|
||||
public int Port { get; set; } = 21;
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Benutzername")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Passwort")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Category("FTP Lokal")]
|
||||
[DisplayName("Root-Pfad")]
|
||||
[Description("Basisverzeichnis für Dateiübertragungen")]
|
||||
public string RootPath { get; set; } = "./data/";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["host"] = Host,
|
||||
["port"] = Port,
|
||||
["username"] = Username,
|
||||
["password"] = Password,
|
||||
["rootPath"] = RootPath
|
||||
};
|
||||
|
||||
public static FTPToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Host = ConfigHelper.GetString(config, "host"),
|
||||
Port = ConfigHelper.GetInt(config, "port", 21),
|
||||
Username = ConfigHelper.GetString(config, "username"),
|
||||
Password = ConfigHelper.GetString(config, "password"),
|
||||
RootPath = ConfigHelper.GetString(config, "rootPath", "./data/")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class TelegramToolSettings
|
||||
{
|
||||
[Category("Telegram")]
|
||||
[DisplayName("Bot-Token")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string BotToken { get; set; } = "";
|
||||
|
||||
[Category("Telegram")]
|
||||
[DisplayName("Standard Chat-ID")]
|
||||
[Description("Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.")]
|
||||
public string DefaultChatId { get; set; } = "";
|
||||
|
||||
[Category("Telegram - Sicherheit")]
|
||||
[DisplayName("Erlaubte Chat-IDs")]
|
||||
[Description("Komma-getrennte Liste erlaubter Chat-IDs")]
|
||||
public string AllowedChatIds { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["botToken"] = BotToken,
|
||||
["defaultChatId"] = DefaultChatId,
|
||||
["allowedChatIds"] = AllowedChatIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static TelegramToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
BotToken = ConfigHelper.GetString(config, "botToken"),
|
||||
DefaultChatId = ConfigHelper.GetString(config, "defaultChatId"),
|
||||
AllowedChatIds = ConfigHelper.GetStringArray(config, "allowedChatIds")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DirectAPIToolSettings
|
||||
{
|
||||
[Category("DirectAPI")]
|
||||
[DisplayName("Standard-Provider")]
|
||||
public string DefaultProvider { get; set; } = "twelvedata";
|
||||
|
||||
[Category("DirectAPI")]
|
||||
[DisplayName("Cache TTL (Sekunden)")]
|
||||
public int CacheTtlSeconds { get; set; } = 60;
|
||||
|
||||
[Category("DirectAPI - API Keys")]
|
||||
[DisplayName("Twelve Data Key")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string TwelveDataKey { get; set; } = "";
|
||||
|
||||
[Category("DirectAPI - API Keys")]
|
||||
[DisplayName("Alpha Vantage Key")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string AlphaVantageKey { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["defaultProvider"] = DefaultProvider,
|
||||
["cacheTtlSeconds"] = CacheTtlSeconds,
|
||||
["providers"] = new Dictionary<string, object?>
|
||||
{
|
||||
["twelvedata"] = new { apiKey = TwelveDataKey },
|
||||
["alphavantage"] = new { apiKey = AlphaVantageKey }
|
||||
}
|
||||
};
|
||||
|
||||
public static DirectAPIToolSettings FromConfig(Dictionary<string, object?> config)
|
||||
{
|
||||
var settings = new DirectAPIToolSettings
|
||||
{
|
||||
DefaultProvider = ConfigHelper.GetString(config, "defaultProvider", "twelvedata"),
|
||||
CacheTtlSeconds = ConfigHelper.GetInt(config, "cacheTtlSeconds", 60)
|
||||
};
|
||||
|
||||
if (config.GetValueOrDefault("providers") is JsonElement providersJe)
|
||||
{
|
||||
var providers = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(providersJe.GetRawText());
|
||||
if (providers != null)
|
||||
{
|
||||
if (providers.TryGetValue("twelvedata", out var td) && td.TryGetProperty("apiKey", out var tdk))
|
||||
settings.TwelveDataKey = tdk.GetString() ?? "";
|
||||
if (providers.TryGetValue("alphavantage", out var av) && av.TryGetProperty("apiKey", out var avk))
|
||||
settings.AlphaVantageKey = avk.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebFetchToolSettings
|
||||
{
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("Erlaubte Domains")]
|
||||
[Description("Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)")]
|
||||
public string AllowedDomains { get; set; } = "";
|
||||
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("Max Response KB")]
|
||||
public int MaxResponseKb { get; set; } = 512;
|
||||
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("User Agent")]
|
||||
public string UserAgent { get; set; } = "ClawdDotNet-Agent/1.0";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["allowedDomains"] = AllowedDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["maxResponseKb"] = MaxResponseKb,
|
||||
["userAgent"] = UserAgent
|
||||
};
|
||||
|
||||
public static WebFetchToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
AllowedDomains = ConfigHelper.GetStringArray(config, "allowedDomains"),
|
||||
MaxResponseKb = ConfigHelper.GetInt(config, "maxResponseKb", 512),
|
||||
UserAgent = ConfigHelper.GetString(config, "userAgent", "ClawdDotNet-Agent/1.0")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class WebMonitorToolSettings
|
||||
{
|
||||
[Category("WebMonitor")]
|
||||
[DisplayName("Monitore (JSON)")]
|
||||
[Description("JSON-Konfiguration der Monitore")]
|
||||
public string MonitorsJson { get; set; } = "{}";
|
||||
|
||||
public Dictionary<string, object?> ToConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["monitors"] = JsonSerializer.Deserialize<Dictionary<string, object?>>(MonitorsJson) ?? new()
|
||||
};
|
||||
}
|
||||
catch { return new Dictionary<string, object?> { ["monitors"] = new Dictionary<string, object?>() }; }
|
||||
}
|
||||
|
||||
public static WebMonitorToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
MonitorsJson = config.GetValueOrDefault("monitors") is JsonElement je ? je.GetRawText() : "{}"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class AgentCommToolSettings
|
||||
{
|
||||
[Category("AgentComm")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentCommToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public sealed class SocialMediaManagerToolSettings
|
||||
{
|
||||
// ─── X (Twitter) ───
|
||||
|
||||
[Category("1. X (Twitter) - API")]
|
||||
[DisplayName("Bearer Token")]
|
||||
[Description("X API v2 Bearer Token für die Authentifizierung")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string XApiKey { get; set; } = "";
|
||||
|
||||
[Category("1. X (Twitter) - Monitoring")]
|
||||
[DisplayName("Überwachte Accounts")]
|
||||
[Description("Komma-getrennte Liste von X-Accounts die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone")]
|
||||
public string XWatchAccounts { get; set; } = "";
|
||||
|
||||
// ─── Reddit ───
|
||||
|
||||
[Category("2. Reddit - Monitoring")]
|
||||
[DisplayName("Überwachte Subreddits")]
|
||||
[Description("Komma-getrennte Liste von Subreddits die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options")]
|
||||
public string RedditWatchSubreddits { get; set; } = "";
|
||||
|
||||
[Category("2. Reddit - Monitoring")]
|
||||
[DisplayName("Posts pro Subreddit")]
|
||||
[Description("Maximale Anzahl Posts die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)")]
|
||||
public int RedditPostLimit { get; set; } = 15;
|
||||
|
||||
// ─── YouTube / STT ───
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("OpenRouter API Key")]
|
||||
[Description("API Key für Speech-to-Text Transkription über OpenRouter")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string OpenRouterApiKey { get; set; } = "";
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("STT Modell")]
|
||||
[Description("OpenRouter Modell-ID für die Transkription")]
|
||||
public string STTModel { get; set; } = "openai/whisper-1";
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("YouTube Kanäle")]
|
||||
[Description("Komma-getrennte Liste von YouTube Kanal-URLs für automatische Überwachung")]
|
||||
public string YoutubeChannels { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["xApiKey"] = XApiKey,
|
||||
["xWatchAccounts"] = SplitToArray(XWatchAccounts),
|
||||
["redditWatchSubreddits"] = SplitToArray(RedditWatchSubreddits),
|
||||
["redditPostLimit"] = RedditPostLimit,
|
||||
["openRouterApiKey"] = OpenRouterApiKey,
|
||||
["sttModel"] = STTModel,
|
||||
["youtubeChannels"] = SplitToArray(YoutubeChannels)
|
||||
};
|
||||
|
||||
public static SocialMediaManagerToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
XApiKey = ConfigHelper.GetString(config, "xApiKey"),
|
||||
XWatchAccounts = ConfigHelper.GetStringArray(config, "xWatchAccounts"),
|
||||
RedditWatchSubreddits = ConfigHelper.GetStringArray(config, "redditWatchSubreddits"),
|
||||
RedditPostLimit = ConfigHelper.GetInt(config, "redditPostLimit", 15),
|
||||
OpenRouterApiKey = ConfigHelper.GetString(config, "openRouterApiKey"),
|
||||
STTModel = ConfigHelper.GetString(config, "sttModel", "openai/whisper-1"),
|
||||
YoutubeChannels = ConfigHelper.GetStringArray(config, "youtubeChannels")
|
||||
};
|
||||
|
||||
private static string[] SplitToArray(string csv)
|
||||
=> csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
public sealed class AgentEditorToolSettings
|
||||
{
|
||||
[Category("AgentEditor")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen, zu bearbeiten und neue Agenten zu erstellen. Keine weitere Konfiguration nötig.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentEditorToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public sealed class AgentSpawnToolSettings
|
||||
{
|
||||
[Category("AgentSpawn")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentSpawnToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public static class ToolSettingsFactory
|
||||
{
|
||||
public static object? CreateViewModel(string toolName, Dictionary<string, object?>? config)
|
||||
{
|
||||
config ??= new();
|
||||
return toolName switch
|
||||
{
|
||||
"FileRW" => FileRWToolSettings.FromConfig(config),
|
||||
"Mail" => MailToolSettings.FromConfig(config),
|
||||
"Database" => DatabaseToolSettings.FromConfig(config),
|
||||
"Telegram" => TelegramToolSettings.FromConfig(config),
|
||||
"FTP" => FTPToolSettings.FromConfig(config),
|
||||
"DirectAPI" => DirectAPIToolSettings.FromConfig(config),
|
||||
"WebFetch" => WebFetchToolSettings.FromConfig(config),
|
||||
"WebMonitor" => WebMonitorToolSettings.FromConfig(config),
|
||||
"AgentComm" => AgentCommToolSettings.FromConfig(config),
|
||||
"SocialMediaManager" => SocialMediaManagerToolSettings.FromConfig(config),
|
||||
"AgentSpawn" => AgentSpawnToolSettings.FromConfig(config),
|
||||
"AgentEditor" => AgentEditorToolSettings.FromConfig(config),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?>? ToConfig(string toolName, object? viewModel)
|
||||
{
|
||||
return viewModel switch
|
||||
{
|
||||
FileRWToolSettings f => f.ToConfig(),
|
||||
MailToolSettings m => m.ToConfig(),
|
||||
DatabaseToolSettings d => d.ToConfig(),
|
||||
TelegramToolSettings t => t.ToConfig(),
|
||||
FTPToolSettings ftp => ftp.ToConfig(),
|
||||
DirectAPIToolSettings dapi => dapi.ToConfig(),
|
||||
WebFetchToolSettings wf => wf.ToConfig(),
|
||||
WebMonitorToolSettings wm => wm.ToConfig(),
|
||||
AgentCommToolSettings ac => ac.ToConfig(),
|
||||
SocialMediaManagerToolSettings smm => smm.ToConfig(),
|
||||
AgentSpawnToolSettings asp => asp.ToConfig(),
|
||||
AgentEditorToolSettings ae => ae.ToConfig(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
|
||||
///
|
||||
/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
|
||||
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
|
||||
/// mitsichern will, macht das von Hand.
|
||||
///
|
||||
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit
|
||||
/// eine Änderung ohne Neustart greift.
|
||||
///
|
||||
/// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine
|
||||
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
|
||||
/// einen <see cref="TimeProvider"/>: läuft ohne Oberfläche, driftet nicht, und Tests
|
||||
/// können die Zeit steuern statt zu warten.</para>
|
||||
/// </summary>
|
||||
public sealed class BackupScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly string _instanceDir;
|
||||
private readonly string _instanceName;
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly TimeSpan _tick;
|
||||
private readonly BackupService _service = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>Verhindert mehrere Sicherungen am selben Tag.</summary>
|
||||
private DateTime? _lastRun;
|
||||
|
||||
public event Action<string>? OnBackupCreated;
|
||||
|
||||
public BackupScheduler(
|
||||
string instanceDir,
|
||||
string instanceName,
|
||||
SettingsManager settings,
|
||||
ILogger logger,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null)
|
||||
{
|
||||
_instanceDir = instanceDir;
|
||||
_instanceName = instanceName;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
|
||||
// Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
|
||||
_tick = tick ?? TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public void Start() => _loop ??= RunLoopAsync(_cts.Token);
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(_tick, _clock);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Ein Fehlschlag darf die Anwendung nicht stören.
|
||||
_logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
|
||||
public async Task TickAsync()
|
||||
{
|
||||
var settings = _settings.AppSettings;
|
||||
if (!settings.AutoBackupEnabled)
|
||||
return;
|
||||
|
||||
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
|
||||
return;
|
||||
|
||||
var now = _clock.GetLocalNow().DateTime;
|
||||
|
||||
// Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
|
||||
if (now.TimeOfDay < scheduled) return;
|
||||
if (_lastRun?.Date == now.Date) return;
|
||||
|
||||
// Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht,
|
||||
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
|
||||
_lastRun = now;
|
||||
|
||||
await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now);
|
||||
}
|
||||
|
||||
private async Task RunAsync(string folder, int keepCount, DateTime now)
|
||||
{
|
||||
// PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
|
||||
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
|
||||
// Windows nicht mehr anlegen lässt.
|
||||
var safeName = PortableFileName.Sanitize(_instanceName, "Instanz");
|
||||
|
||||
var file = Path.Combine(
|
||||
Path.GetFullPath(folder),
|
||||
$"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
|
||||
|
||||
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
|
||||
{
|
||||
Secrets = SecretMode.Exclude,
|
||||
IncludeChatHistory = true,
|
||||
IncludeLogs = false
|
||||
});
|
||||
|
||||
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
|
||||
result.ZipPath, result.SizeBytes);
|
||||
|
||||
ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
|
||||
|
||||
OnBackupCreated?.Invoke(result.ZipPath);
|
||||
}
|
||||
|
||||
/// <summary>Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.</summary>
|
||||
private void ApplyRotation(string folder, string safeName, int keepCount)
|
||||
{
|
||||
if (keepCount <= 0 || !Directory.Exists(folder))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var prefix = $"backup_{safeName}_";
|
||||
|
||||
var obsolete = new DirectoryInfo(folder)
|
||||
.GetFiles("*.zip")
|
||||
.Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison))
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.Skip(keepCount)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in obsolete)
|
||||
{
|
||||
file.Delete();
|
||||
_logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat,
|
||||
/// Fehler-Stream, Bugtracker und Update-Prüfung.
|
||||
///
|
||||
/// <para>Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz
|
||||
/// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides
|
||||
/// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu
|
||||
/// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab.</para>
|
||||
///
|
||||
/// <para>Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein
|
||||
/// <c>status</c>/<c>error</c>-Umschlag), einen eigenen Zwischenspeicher und muss vor
|
||||
/// allem anderen laufen. Dafür ist <see cref="LicenseGate"/> zuständig.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterService : IAsyncDisposable
|
||||
{
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly string _appToken;
|
||||
private readonly string _build;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private WatchdogHeartbeatService? _heartbeat;
|
||||
|
||||
public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden.</summary>
|
||||
public BugtrackerClient? Bugtracker { get; private set; }
|
||||
|
||||
/// <summary>Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist.</summary>
|
||||
public UpdateAvailability? Update { get; private set; }
|
||||
|
||||
private DeploymentcenterService(
|
||||
DeploymentcenterApi api, string appToken, string build, ILogger logger)
|
||||
{
|
||||
_api = api;
|
||||
_appToken = appToken;
|
||||
_build = build;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt <c>null</c> zurück,
|
||||
/// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines
|
||||
/// dieser Module darf ein Startgrund oder ein Hindernis sein.
|
||||
/// </summary>
|
||||
public static DeploymentcenterService? TryCreate(
|
||||
AppSettings settings, string build, ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl))
|
||||
{
|
||||
logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt – Anbindung aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Deploymentcenter: kein Token hinterlegt – Heartbeat, Fehler-Stream und "
|
||||
+ "Bugtracker bleiben aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
DeploymentcenterApi api;
|
||||
try
|
||||
{
|
||||
api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar – Anbindung aus.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var service = new DeploymentcenterService(
|
||||
api, settings.DeploymentcenterToken, build, logger);
|
||||
|
||||
if (settings.ErrorReportingEnabled)
|
||||
{
|
||||
service.Errors = new ErrorReporter(
|
||||
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger);
|
||||
}
|
||||
|
||||
service.Bugtracker = new BugtrackerClient(
|
||||
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build);
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
// ─── Watchdog ───
|
||||
|
||||
/// <summary>
|
||||
/// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist.
|
||||
///
|
||||
/// <para>Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über
|
||||
/// das Paar <c>source</c> + <c>instance</c>. Fällt eine von mehreren aus, fällt
|
||||
/// genau deren Monitor — und nur der schlägt Alarm.</para>
|
||||
/// </summary>
|
||||
/// <param name="saveInstanceConfig">
|
||||
/// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration
|
||||
/// geschrieben werden soll (dort verschlüsselt).
|
||||
/// </param>
|
||||
public async Task StartWatchdogAsync(
|
||||
InstanceConfig instance,
|
||||
IInstanceHealthProvider health,
|
||||
Action saveInstanceConfig,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog);
|
||||
if (service is not { Enabled: true, AutoStart: true })
|
||||
return;
|
||||
|
||||
var watchdog = instance.Watchdog;
|
||||
var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct);
|
||||
|
||||
try
|
||||
{
|
||||
_heartbeat = WatchdogHeartbeatService.Create(
|
||||
_api.BaseUrl,
|
||||
token,
|
||||
watchdog.Source,
|
||||
watchdog.ResolveInstance(instance.InstanceId),
|
||||
watchdog.Group,
|
||||
DescribePlatform(),
|
||||
_build,
|
||||
watchdog.IntervalSeconds,
|
||||
health,
|
||||
_logger);
|
||||
|
||||
_heartbeat.Start();
|
||||
|
||||
_logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s",
|
||||
watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte
|
||||
/// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite.
|
||||
///
|
||||
/// <para>Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das
|
||||
/// Master-Token liegt, sondern ein auf <c>watchdog:ping</c> und
|
||||
/// <c>bugtracker:report</c> beschränktes, das sich einzeln widerrufen lässt.
|
||||
/// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter
|
||||
/// Rechtelage läuft, ist genau dann still, wenn man es braucht.</para>
|
||||
/// </summary>
|
||||
private async Task<string> ResolveInstanceTokenAsync(
|
||||
InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct)
|
||||
{
|
||||
if (instance.Watchdog.HasToken)
|
||||
return instance.Watchdog.AgentToken;
|
||||
|
||||
try
|
||||
{
|
||||
var provisioned = await new TokenProvisioner(_api).ProvisionAsync(
|
||||
clientName: $"ClawdDotNet {instance.InstanceName}",
|
||||
instanceId: instance.InstanceId,
|
||||
scopes: TokenProvisioner.InstanceScopes,
|
||||
ct: ct);
|
||||
|
||||
instance.Watchdog.AgentToken = provisioned.Token;
|
||||
saveInstanceConfig();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})",
|
||||
provisioned.TokenId, string.Join(", ", provisioned.Scopes));
|
||||
|
||||
return provisioned.Token;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf
|
||||
// keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben.
|
||||
_logger.LogInformation(
|
||||
"Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) – "
|
||||
+ "es wird mit dem hinterlegten Token gemeldet.", ex.Message);
|
||||
|
||||
return _appToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribePlatform() =>
|
||||
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / "
|
||||
+ $".NET {System.Environment.Version}";
|
||||
|
||||
// ─── Updates ───
|
||||
|
||||
/// <summary>
|
||||
/// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das
|
||||
/// Ergebnis wird protokolliert und über <see cref="Update"/> bereitgestellt; ob und
|
||||
/// wann aktualisiert wird, entscheidet der Benutzer.
|
||||
/// </summary>
|
||||
public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default)
|
||||
{
|
||||
if (!settings.UpdateCheckEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var client = new global::Deploymentcenter.Client.UpdateClient();
|
||||
var result = await client.CheckForUpdateAsync(
|
||||
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
|
||||
settings.UpdateChannel, cancellationToken: ct);
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
_logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische
|
||||
// latest.json in camelCase, die API in snake_case, jeweils über ein eigenes
|
||||
// Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an —
|
||||
// und der ist genau der Rückfall, wenn die latest.json fehlt.
|
||||
Update = new UpdateAvailability(
|
||||
result.UpdateAvailable,
|
||||
result.LatestRelease?.Version ?? currentVersion,
|
||||
result.IsCritical,
|
||||
result.LatestRelease?.Changelog,
|
||||
result.LatestRelease?.PackageUrl);
|
||||
|
||||
if (result.UpdateAvailable)
|
||||
{
|
||||
_logger.LogInformation("Update verfügbar: {Version}{Critical}",
|
||||
Update.LatestVersion, Update.IsCritical ? " (kritisch)" : "");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert).");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_heartbeat is not null)
|
||||
await _heartbeat.DisposeAsync();
|
||||
|
||||
if (Errors is IDisposable disposableReporter)
|
||||
disposableReporter.Dispose();
|
||||
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis der Update-Prüfung.</summary>
|
||||
/// <param name="DownloadUrl">
|
||||
/// Adresse des Pakets — für einen späteren Anschluss des <c>update-agent</c>. Solange
|
||||
/// der nicht eingebunden ist, dient sie nur der Anzeige.
|
||||
/// </param>
|
||||
public sealed record UpdateAvailability(
|
||||
bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes,
|
||||
string? DownloadUrl = null);
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wie der <see cref="LicenseGate"/> mit dem Benutzer spricht.
|
||||
///
|
||||
/// Vorher rief er unmittelbar <c>MessageBox.Show</c> und einen WinForms-Dialog.
|
||||
/// Das band die Lizenzprüfung an WinForms — und wäre im kopflosen Betrieb fatal
|
||||
/// gewesen: Ein Dienst, der beim Start ein Fenster öffnet und auf eine Eingabe wartet,
|
||||
/// hängt für immer, ohne dass jemand die Meldung je zu sehen bekäme.
|
||||
///
|
||||
/// Es gibt drei Umsetzungen:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AvaloniaLicensePrompt</c> — Dialoge im Fenster (Desktop).</item>
|
||||
/// <item><see cref="ConsoleLicensePrompt"/> — Eingabe über die Konsole (Kommandozeile).</item>
|
||||
/// <item><see cref="NonInteractiveLicensePrompt"/> — antwortet nie (systemd-Dienst).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface ILicensePrompt
|
||||
{
|
||||
/// <summary>
|
||||
/// Fragt einen Lizenzschlüssel ab. <c>null</c> heißt Abbruch — der Aufrufer beendet
|
||||
/// die Anwendung.
|
||||
/// </summary>
|
||||
/// <param name="hardwareId">Wird angezeigt, damit der Nutzer ihn an den Support geben kann.</param>
|
||||
/// <param name="problem">Warum der bisherige Schlüssel nicht taugt; <c>null</c> beim ersten Fragen.</param>
|
||||
/// <param name="currentKey">Vorbelegung des Eingabefelds.</param>
|
||||
Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey);
|
||||
|
||||
/// <summary>Eine Meldung, die keine Antwort braucht (etwa „offline gültig bis …").</summary>
|
||||
Task ShowInfoAsync(string title, string message);
|
||||
|
||||
/// <summary>Ein Fehler, nach dem die Anwendung nicht weiterläuft.</summary>
|
||||
Task ShowErrorAsync(string title, string message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab.
|
||||
///
|
||||
/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren
|
||||
/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile
|
||||
/// hinterlegt (<c>--license-set-key</c>).
|
||||
/// </summary>
|
||||
public sealed class NonInteractiveLicensePrompt(Action<string> log) : ILicensePrompt
|
||||
{
|
||||
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
{
|
||||
log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. "
|
||||
+ (problem is null ? "" : $"Grund: {problem}. ")
|
||||
+ "Schlüssel mit '--license-set-key <SCHLÜSSEL>' hinterlegen.");
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
{
|
||||
log($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
{
|
||||
log($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eingabe über die Konsole — für Kommandozeilenaufrufe.</summary>
|
||||
public sealed class ConsoleLicensePrompt : ILicensePrompt
|
||||
{
|
||||
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
{
|
||||
if (problem is not null)
|
||||
Console.Error.WriteLine($"Lizenz: {problem}");
|
||||
|
||||
Console.WriteLine($"Hardware-ID: {hardwareId}");
|
||||
Console.Write("Lizenzschlüssel: ");
|
||||
|
||||
var input = Console.ReadLine()?.Trim();
|
||||
return Task.FromResult(string.IsNullOrWhiteSpace(input) ? null : input);
|
||||
}
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
{
|
||||
Console.WriteLine($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
{
|
||||
Console.Error.WriteLine($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.App.Models;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
|
||||
///
|
||||
/// Layout:
|
||||
/// {InstancesDir}/
|
||||
/// ├── Instance-{Name}/
|
||||
/// │ ├── InstanceSettings.json
|
||||
/// │ ├── TokenUsage.json
|
||||
/// │ └── Agents/
|
||||
/// │ ├── AgentList.json
|
||||
/// │ └── Agent-{Name}/
|
||||
/// │ ├── AgentSettings.json
|
||||
/// │ ├── Soul.md
|
||||
/// │ ├── Identity.md
|
||||
/// │ ├── Logs/
|
||||
/// │ └── Workspace/
|
||||
/// </summary>
|
||||
public sealed class InstanceDirectoryManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _instancesDir;
|
||||
private readonly Lock _tokenUsageLock = new();
|
||||
|
||||
public InstanceDirectoryManager(string instancesDirectory)
|
||||
{
|
||||
_instancesDir = Path.GetFullPath(instancesDirectory);
|
||||
Directory.CreateDirectory(_instancesDir);
|
||||
}
|
||||
|
||||
public string InstancesDirectory => _instancesDir;
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// INSTANZ-OPERATIONEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public static string BuildInstanceFolderName(string instanceName)
|
||||
=> $"Instance-{SanitizeName(instanceName)}";
|
||||
|
||||
public string GetInstancePath(string instanceName)
|
||||
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
|
||||
|
||||
public List<InstanceInfo> ListInstances()
|
||||
{
|
||||
var result = new List<InstanceInfo>();
|
||||
|
||||
if (!Directory.Exists(_instancesDir))
|
||||
return result;
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
|
||||
{
|
||||
var folderName = Path.GetFileName(dir);
|
||||
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
|
||||
|
||||
var info = new InstanceInfo
|
||||
{
|
||||
FolderName = folderName,
|
||||
FolderPath = dir,
|
||||
InstanceName = folderName.Replace("Instance-", "")
|
||||
};
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
|
||||
if (config is not null)
|
||||
{
|
||||
info.InstanceName = config.InstanceName;
|
||||
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
|
||||
? "Fehlt" : "Konfiguriert";
|
||||
}
|
||||
}
|
||||
catch { /* defekte Config → Standardwerte */ }
|
||||
}
|
||||
|
||||
// Agenten zählen
|
||||
var agentsDir = Path.Combine(dir, "Agents");
|
||||
if (Directory.Exists(agentsDir))
|
||||
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
|
||||
|
||||
result.Add(info);
|
||||
}
|
||||
|
||||
return result.OrderBy(i => i.InstanceName).ToList();
|
||||
}
|
||||
|
||||
public string CreateInstance(string instanceName)
|
||||
{
|
||||
var instanceDir = GetInstancePath(instanceName);
|
||||
|
||||
if (Directory.Exists(instanceDir))
|
||||
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
|
||||
|
||||
// Hauptverzeichnis
|
||||
Directory.CreateDirectory(instanceDir);
|
||||
|
||||
// Agents-Unterverzeichnis
|
||||
var agentsDir = Path.Combine(instanceDir, "Agents");
|
||||
Directory.CreateDirectory(agentsDir);
|
||||
|
||||
// SharedWorkspace-Verzeichnis
|
||||
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
|
||||
// InstanceSettings.json
|
||||
var config = new InstanceConfig
|
||||
{
|
||||
InstanceId = Guid.NewGuid().ToString("N")[..8],
|
||||
InstanceName = instanceName,
|
||||
LogDirectory = "./Logs",
|
||||
WorkingDirectory = instanceDir
|
||||
};
|
||||
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
|
||||
|
||||
// TokenUsage.json (leer)
|
||||
var tokenUsage = new TokenUsageFile
|
||||
{
|
||||
InstanceId = config.InstanceId,
|
||||
InstanceName = instanceName
|
||||
};
|
||||
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
|
||||
|
||||
// AgentList.json (leer)
|
||||
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
|
||||
|
||||
return instanceDir;
|
||||
}
|
||||
|
||||
public InstanceConfig LoadInstanceConfig(string instanceDir)
|
||||
{
|
||||
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
||||
|
||||
if (!File.Exists(settingsPath))
|
||||
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
|
||||
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
|
||||
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
|
||||
|
||||
// Agenten aus Verzeichnisstruktur laden
|
||||
config.Agents.Clear();
|
||||
var agentsDir = Path.Combine(instanceDir, "Agents");
|
||||
|
||||
// Sicherstellen, dass SharedWorkspace existiert (Migration)
|
||||
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
|
||||
// AgentList.json für Descriptions laden
|
||||
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
|
||||
var agentList = File.Exists(agentListPath)
|
||||
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
||||
: new AgentListFile();
|
||||
|
||||
if (Directory.Exists(agentsDir))
|
||||
{
|
||||
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
|
||||
{
|
||||
var agent = LoadAgentConfig(agentDir);
|
||||
|
||||
var folderName = Path.GetFileName(agentDir);
|
||||
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
|
||||
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
|
||||
agent.Description = listEntry.Description;
|
||||
|
||||
config.Agents.Add(agent);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
|
||||
{
|
||||
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
||||
|
||||
// Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz
|
||||
// braucht sie danach wieder im Klartext.
|
||||
ConfigSecrets.Protect(config);
|
||||
try
|
||||
{
|
||||
SaveJson(settingsPath, config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConfigSecrets.Unprotect(config);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// AGENTEN-OPERATIONEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public static string BuildAgentFolderName(string agentName)
|
||||
=> $"Agent-{SanitizeName(agentName)}";
|
||||
|
||||
public string GetAgentPath(string instanceDir, string agentName)
|
||||
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
|
||||
|
||||
public string CreateAgent(string instanceDir, string agentName, string description = "")
|
||||
{
|
||||
var agentDir = GetAgentPath(instanceDir, agentName);
|
||||
|
||||
if (Directory.Exists(agentDir))
|
||||
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
|
||||
|
||||
// Verzeichnisse anlegen
|
||||
Directory.CreateDirectory(agentDir);
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
|
||||
|
||||
// AgentSettings.json
|
||||
var agentConfig = new AgentConfig
|
||||
{
|
||||
AgentId = SanitizeName(agentName).ToLowerInvariant(),
|
||||
DisplayName = agentName,
|
||||
Model = "anthropic/claude-sonnet-4-5"
|
||||
};
|
||||
SaveAgentSettings(agentDir, agentConfig);
|
||||
|
||||
// Identity.md
|
||||
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"),
|
||||
$"""
|
||||
# Identity: {agentName}
|
||||
|
||||
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
|
||||
|
||||
## Rolle
|
||||
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
|
||||
|
||||
## Expertise
|
||||
[Beschreibe hier die Fachgebiete und Fähigkeiten]
|
||||
|
||||
## Kontext
|
||||
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
|
||||
""");
|
||||
|
||||
// Soul.md
|
||||
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"),
|
||||
$"""
|
||||
# Soul: {agentName}
|
||||
|
||||
## Persönlichkeit
|
||||
- Gründlich und zuverlässig
|
||||
- Klar und präzise in der Kommunikation
|
||||
- Proaktiv bei der Problemerkennung
|
||||
|
||||
## Arbeitsweise
|
||||
- Analysiere Aufgaben sorgfältig bevor du handelst
|
||||
- Dokumentiere deine Entscheidungen und Ergebnisse
|
||||
- Nutze die dir zugewiesenen Tools effizient
|
||||
|
||||
## Werte
|
||||
- Genauigkeit vor Geschwindigkeit
|
||||
- Transparenz in der Entscheidungsfindung
|
||||
- Sicherheit und Datenschutz haben Priorität
|
||||
""");
|
||||
|
||||
// AgentList.json aktualisieren
|
||||
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
|
||||
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
public AgentConfig LoadAgentConfig(string agentDir)
|
||||
{
|
||||
// AgentSettings.json laden
|
||||
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
||||
AgentConfig config;
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
|
||||
ConfigLoader.Migrate(config);
|
||||
ConfigSecrets.Unprotect(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config = new AgentConfig
|
||||
{
|
||||
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
|
||||
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
|
||||
};
|
||||
}
|
||||
|
||||
// Identity.md laden
|
||||
var identityPath = Path.Combine(agentDir, "Identity.md");
|
||||
if (File.Exists(identityPath))
|
||||
config.Identity = File.ReadAllText(identityPath);
|
||||
|
||||
// Soul.md laden
|
||||
var soulPath = Path.Combine(agentDir, "Soul.md");
|
||||
if (File.Exists(soulPath))
|
||||
config.Soul = File.ReadAllText(soulPath);
|
||||
|
||||
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
|
||||
config.AgentDir = Path.GetFullPath(agentDir);
|
||||
|
||||
// Workspace-Pfad setzen
|
||||
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
|
||||
|
||||
// SharedWorkspace-Pfad setzen
|
||||
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
|
||||
if (agentsDir != null)
|
||||
{
|
||||
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public void SaveAgentSettings(string agentDir, AgentConfig config)
|
||||
{
|
||||
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
||||
|
||||
ConfigSecrets.Protect(config);
|
||||
try
|
||||
{
|
||||
SaveJson(settingsPath, config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConfigSecrets.Unprotect(config);
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveAgentIdentity(string agentDir, string identity)
|
||||
{
|
||||
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
|
||||
}
|
||||
|
||||
public void SaveAgentSoul(string agentDir, string soul)
|
||||
{
|
||||
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
|
||||
}
|
||||
|
||||
public void RemoveAgent(string instanceDir, string agentFolderName)
|
||||
{
|
||||
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
|
||||
if (Directory.Exists(agentDir))
|
||||
Directory.Delete(agentDir, recursive: true);
|
||||
|
||||
// AgentList.json aktualisieren
|
||||
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
||||
if (File.Exists(agentListPath))
|
||||
{
|
||||
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
|
||||
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
|
||||
SaveJson(agentListPath, list);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// TOKEN USAGE
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
|
||||
{
|
||||
lock (_tokenUsageLock)
|
||||
{
|
||||
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
||||
TokenUsageFile file;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Korrupte Datei: Backup erstellen, neu anfangen
|
||||
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
|
||||
file = new TokenUsageFile();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
file = new TokenUsageFile();
|
||||
}
|
||||
|
||||
file.Records.Add(record);
|
||||
SaveJson(path, file);
|
||||
}
|
||||
}
|
||||
|
||||
public TokenUsageFile LoadTokenUsage(string instanceDir)
|
||||
{
|
||||
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
||||
return File.Exists(path)
|
||||
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
|
||||
: new TokenUsageFile();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// HILFSMETHODEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
|
||||
{
|
||||
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
||||
var list = File.Exists(agentListPath)
|
||||
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
||||
: new AgentListFile();
|
||||
|
||||
// Duplikat-Check
|
||||
if (list.Agents.All(a => a.FolderName != folderName))
|
||||
{
|
||||
list.Agents.Add(new AgentListItem
|
||||
{
|
||||
Name = agentName,
|
||||
Description = description,
|
||||
FolderName = folderName
|
||||
});
|
||||
}
|
||||
|
||||
SaveJson(agentListPath, list);
|
||||
}
|
||||
|
||||
private static void SaveJson<T>(string path, T obj)
|
||||
{
|
||||
// Atomar: Ein Absturz mitten im Schreiben soll keine halbe Datei hinterlassen.
|
||||
// Genau das ist bereits passiert (TokenUsage.json.corrupt_…).
|
||||
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(obj, JsonOpts));
|
||||
}
|
||||
|
||||
private static T? LoadJson<T>(string path)
|
||||
{
|
||||
// Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText.
|
||||
var json = AtomicFile.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOpts);
|
||||
}
|
||||
|
||||
private static string SanitizeName(string name)
|
||||
{
|
||||
var sanitized = name.Trim();
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
sanitized = sanitized.Replace(c, '_');
|
||||
return sanitized.Replace(' ', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.App.Models;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe JSON persistence for job execution history.
|
||||
/// Uses file locking to handle concurrent access from multiple schedulers.
|
||||
/// </summary>
|
||||
public sealed class JobHistoryService
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly int _maxEntries;
|
||||
private List<JobHistoryEntry> _entries = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public JobHistoryService(string instancePath, int maxEntries = 500)
|
||||
{
|
||||
_filePath = Path.Combine(instancePath, "job_history.json");
|
||||
_maxEntries = maxEntries;
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry to the history (thread-safe, persists immediately).
|
||||
/// </summary>
|
||||
public void Add(JobHistoryEntry entry)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Insert(0, entry); // newest first
|
||||
|
||||
// Trim old entries
|
||||
if (_entries.Count > _maxEntries)
|
||||
_entries = _entries.Take(_maxEntries).ToList();
|
||||
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all entries (newest first).
|
||||
/// </summary>
|
||||
public List<JobHistoryEntry> GetAll()
|
||||
{
|
||||
lock (_lock)
|
||||
return new List<JobHistoryEntry>(_entries);
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
_entries = new List<JobHistoryEntry>();
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
_entries = JsonSerializer.Deserialize<List<JobHistoryEntry>>(stream, JsonOptions)
|
||||
?? new List<JobHistoryEntry>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_entries = new List<JobHistoryEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
JsonSerializer.Serialize(stream, _entries, JsonOptions);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore write failures — next save will retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
|
||||
///
|
||||
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
|
||||
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
|
||||
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
|
||||
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
|
||||
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
|
||||
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
|
||||
/// aussperren.</para>
|
||||
///
|
||||
/// <para>Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
|
||||
/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
|
||||
/// trägt damit über Ausfälle hinweg — begrenzt durch <c>cache_ttl_hours</c> des Projekts
|
||||
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
|
||||
///
|
||||
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
|
||||
/// verlangt den <c>shared_key</c> des Servers. Der gehört nicht in eine ausgelieferte
|
||||
/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
|
||||
/// Eine Signaturprüfung findet nicht statt — siehe <see cref="LicenseInfo"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseGate
|
||||
{
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILicensePrompt _prompt;
|
||||
private readonly LicenseClient _client;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
private string? _hardwareId;
|
||||
|
||||
public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_prompt = prompt;
|
||||
|
||||
_serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
|
||||
? LicenseInfo.DefaultServerUrl
|
||||
: settings.AppSettings.DeploymentcenterUrl.Trim();
|
||||
|
||||
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
|
||||
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
|
||||
LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
|
||||
|
||||
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
|
||||
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
|
||||
// Server nicht antwortete.
|
||||
_client = new LicenseClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
|
||||
/// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
|
||||
/// eintragen kann.
|
||||
/// </summary>
|
||||
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
|
||||
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
|
||||
/// </summary>
|
||||
public string HardwareId =>
|
||||
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
|
||||
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
|
||||
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
|
||||
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
|
||||
/// </summary>
|
||||
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
|
||||
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
|
||||
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
|
||||
/// Anwendung.
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnforcementConfigured)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = _settings.AppSettings.LicenseKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
key = await _prompt.RequestKeyAsync(HardwareId, null, null);
|
||||
if (key is null) return false;
|
||||
}
|
||||
|
||||
var result = await ValidateAsync(key, ct);
|
||||
|
||||
while (!result.IsValid)
|
||||
{
|
||||
// Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
|
||||
// Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
|
||||
// Benutzer raten. Der Notausschalter greift, sobald der Server wieder
|
||||
// antwortet (LicenseWatch).
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.",
|
||||
result.Status, result.Message);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
|
||||
+ "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
|
||||
+ "erneut.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.Status == "clock_rollback")
|
||||
{
|
||||
// Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
|
||||
await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenzprüfung konnte nicht abgeschlossen werden. "
|
||||
+ "Bitte den Support kontaktieren.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})",
|
||||
result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
|
||||
|
||||
var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
|
||||
if (retry is null) return false;
|
||||
|
||||
key = retry;
|
||||
result = await ValidateAsync(key, ct);
|
||||
}
|
||||
|
||||
_settings.AppSettings.LicenseKey = key;
|
||||
|
||||
try
|
||||
{
|
||||
_settings.Save();
|
||||
}
|
||||
catch (SettingsPersistenceException ex)
|
||||
{
|
||||
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
|
||||
// beim nächsten Start wird eben erneut gefragt.
|
||||
_logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
|
||||
}
|
||||
|
||||
if (result.IsCached)
|
||||
{
|
||||
var until = Describe(result.CacheExpiresAt);
|
||||
|
||||
_logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
$"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "not_found",
|
||||
Message = "Kein Lizenzschlüssel hinterlegt."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.ValidateAsync(
|
||||
LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
|
||||
// IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
|
||||
// nicht als Urteil über die Lizenz gelten.
|
||||
_logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "server_unavailable",
|
||||
Message = "Lizenz konnte nicht geprüft werden.",
|
||||
IsTransient = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(long? unixSeconds) =>
|
||||
unixSeconds is { } seconds and > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
|
||||
: "auf Weiteres";
|
||||
|
||||
private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
|
||||
{
|
||||
"revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
|
||||
"suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
|
||||
"expired" => "Diese Lizenz ist abgelaufen.",
|
||||
"activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
|
||||
+ "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
|
||||
+ "über \"Freigeben\" räumen.",
|
||||
// Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
|
||||
// Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
|
||||
// deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
|
||||
// eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
|
||||
// sucht am falschen Ende.
|
||||
"not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
|
||||
+ $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
|
||||
+ "sein und der Schlüssel dort hinterlegt."
|
||||
+ (string.IsNullOrWhiteSpace(result.Message)
|
||||
? ""
|
||||
: $" (Server: {result.Message})"),
|
||||
_ => string.IsNullOrWhiteSpace(result.Message)
|
||||
? "Die Lizenz ist nicht gültig."
|
||||
: result.Message
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fest hinterlegte Lizenz-Eckdaten.
|
||||
///
|
||||
/// <para><b>Kein Public-Key mehr.</b> Die frühere Fassung führte einen
|
||||
/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
|
||||
/// Gegenseite: Der Client liest ausschließlich das Feld <c>status</c> aus der Antwort,
|
||||
/// eine Signaturprüfung findet nicht statt (siehe
|
||||
/// <c>docs/Deploymentcenter-Anbindung-Review.md</c>, Abschnitt 2.1). Ein Schlüssel, der
|
||||
/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
|
||||
/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.</para>
|
||||
///
|
||||
/// <para>Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
|
||||
/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.</para>
|
||||
/// </summary>
|
||||
public static class LicenseInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Projekt-Slug, wie er in <c>dc_projects</c> angelegt ist. Gilt für alle Module:
|
||||
/// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
|
||||
/// Projekttabelle zu.
|
||||
///
|
||||
/// <para>Hier stand bis zur Umstellung <c>clawd</c> — der Name aus dem
|
||||
/// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
|
||||
/// <c>clawddotnet</c>. Der Server beantwortet einen unbekannten Slug mit demselben
|
||||
/// <c>not_found</c> wie einen unbekannten Schlüssel, weshalb das wie ein falsch
|
||||
/// eingegebener Lizenzschlüssel aussah.</para>
|
||||
/// </summary>
|
||||
public const string ProductSlug = "clawddotnet";
|
||||
|
||||
/// <summary>
|
||||
/// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
|
||||
/// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
|
||||
/// und teilen sich diese Adresse.
|
||||
/// </summary>
|
||||
public const string DefaultServerUrl = "https://dc.mhdf.de";
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz im laufenden Betrieb nach.
|
||||
///
|
||||
/// <para>Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
|
||||
/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
|
||||
/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
|
||||
/// Klick.</para>
|
||||
///
|
||||
/// <para><b>Nur ein Urteil zählt.</b> Ein Netzproblem, eine Drosselung oder eine
|
||||
/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
|
||||
/// <c>IsTransient</c>, und ein Serverausfall darf nicht alle laufenden Instanzen
|
||||
/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseWatch : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
|
||||
|
||||
private readonly LicenseGate _gate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>
|
||||
/// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
|
||||
/// ohne Rückfrage; der übergebene Text erklärt den Grund.
|
||||
/// </summary>
|
||||
public event Func<string, Task>? Revoked;
|
||||
|
||||
public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
|
||||
{
|
||||
_gate = gate;
|
||||
_logger = logger;
|
||||
_interval = interval ?? DefaultInterval;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop is { IsCompleted: false })
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = RunAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (result.IsValid)
|
||||
continue;
|
||||
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Lizenz-Nachprüfung ohne Ergebnis ({Status}) – Betrieb läuft weiter.",
|
||||
result.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Lizenz gilt nicht mehr ({Status}) – Instanz wird beendet.",
|
||||
result.Status);
|
||||
|
||||
if (Revoked is { } handler)
|
||||
{
|
||||
await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
|
||||
+ "ClawdDotNet wird beendet.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Regulärer Stopp.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Die Nachprüfung darf den Betrieb nicht mitnehmen.
|
||||
_logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null)
|
||||
return;
|
||||
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>Eine gelesene Logzeile.</summary>
|
||||
/// <param name="Module">Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei.</param>
|
||||
/// <param name="Level">INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat.</param>
|
||||
public readonly record struct LogLine(string Module, string Level, string Text);
|
||||
|
||||
/// <summary>Ab welcher Stufe angezeigt wird.</summary>
|
||||
public enum LogLevelFilter { All, Info, Warn, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Liest neu hinzugekommene Zeilen aus den Logdateien.
|
||||
///
|
||||
/// <para>Die vorige Fassung (<c>LiveLogViewerService</c>) schrieb unmittelbar in eine
|
||||
/// <c>RichTextBox</c> und taktete über einen <c>System.Windows.Forms.Timer</c> — Lesen
|
||||
/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur
|
||||
/// das Lesen; was damit geschieht, entscheidet der Aufrufer.</para>
|
||||
///
|
||||
/// <para>Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das
|
||||
/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen.</para>
|
||||
/// </summary>
|
||||
public sealed partial class LogTail(string logDirectory)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, long> _positions = new();
|
||||
|
||||
[GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LevelPattern();
|
||||
|
||||
/// <summary>Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.</summary>
|
||||
public IReadOnlyList<string> AvailableModules()
|
||||
{
|
||||
var directory = TodayDirectory();
|
||||
if (directory is null) return [];
|
||||
|
||||
return Directory.GetFiles(directory, "*.log")
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.Select(name => name!)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alles, was seit dem letzten Aufruf dazugekommen ist.
|
||||
///
|
||||
/// <paramref name="module"/> leer heißt: alle Module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogLine> ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All)
|
||||
{
|
||||
var directory = TodayDirectory();
|
||||
if (directory is null) return [];
|
||||
|
||||
var result = new List<LogLine>();
|
||||
|
||||
foreach (var path in Directory.GetFiles(directory, "*.log"))
|
||||
{
|
||||
var moduleName = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
if (!string.IsNullOrEmpty(module)
|
||||
&& !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters
|
||||
// die gesamte bisherige Datei auf einmal herein.
|
||||
TrackWithoutReading(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
ReadFile(path, moduleName, level, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReadFile(string path, string module, LogLevelFilter level, List<LogLine> into)
|
||||
{
|
||||
var lastPosition = _positions.GetOrAdd(path, 0L);
|
||||
|
||||
try
|
||||
{
|
||||
// FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die
|
||||
// Datei auch ersetzen können, während wir lesen.
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
|
||||
if (stream.Length < lastPosition)
|
||||
lastPosition = 0; // rotiert oder gekürzt
|
||||
|
||||
if (stream.Length == lastPosition)
|
||||
return;
|
||||
|
||||
stream.Seek(lastPosition, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var lineLevel = ExtractLevel(line);
|
||||
if (!Passes(lineLevel, level)) continue;
|
||||
|
||||
into.Add(new LogLine(module, lineLevel, line));
|
||||
}
|
||||
|
||||
_positions[path] = stream.Position;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Wird gerade geschrieben — beim nächsten Takt erneut versuchen.
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackWithoutReading(string path)
|
||||
{
|
||||
try { _positions[path] = new FileInfo(path).Length; }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
private static string ExtractLevel(string line)
|
||||
{
|
||||
var match = LevelPattern().Match(line);
|
||||
return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
|
||||
}
|
||||
|
||||
private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
|
||||
{
|
||||
LogLevelFilter.All => true,
|
||||
// Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine
|
||||
// Fehlermeldung, die der Filter verschluckt.
|
||||
_ when lineLevel.Length == 0 => true,
|
||||
LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL",
|
||||
LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL",
|
||||
LogLevelFilter.Error => lineLevel is "ERR" or "FTL",
|
||||
_ => true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis des heutigen Tages, oder <c>null</c>.
|
||||
///
|
||||
/// <c>DateTime.Now</c> und nicht UTC: Die Schreibseite legt die Verzeichnisse nach
|
||||
/// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit
|
||||
/// <c>TZ=UTC</c> wechselt der Ordner damit um Mitternacht UTC — richtig, aber
|
||||
/// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht.
|
||||
/// </summary>
|
||||
private string? TodayDirectory()
|
||||
{
|
||||
if (!Directory.Exists(logDirectory)) return null;
|
||||
|
||||
var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
return Directory.Exists(directory) ? directory : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab.
|
||||
///
|
||||
/// Der Takt lief früher über einen <c>System.Windows.Forms.Timer</c> — der braucht eine
|
||||
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/>: läuft
|
||||
/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt.
|
||||
///
|
||||
/// <see cref="OnStatusUpdated"/> wird auf einem Hintergrundfaden ausgelöst. Wer daran
|
||||
/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia
|
||||
/// über <c>Dispatcher.UIThread</c>.
|
||||
/// </summary>
|
||||
public sealed class OpenRouterStatusService : IAsyncDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
|
||||
|
||||
/// <summary>
|
||||
/// Preise kommen vom /models-Endpunkt statt aus einer fest verdrahteten Tabelle.
|
||||
/// Die alte Tabelle war veraltet und enthielt ausgerechnet das Standardmodell der
|
||||
/// Agenten nicht — die Anzeige meldete dafür stillschweigend 0 €.
|
||||
/// </summary>
|
||||
private readonly ModelPricingCatalog _pricing = new();
|
||||
|
||||
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
|
||||
|
||||
private const double UsdToEur = 0.92;
|
||||
|
||||
public bool IsApiReachable { get; private set; }
|
||||
public string StatusText { get; private set; } = "Prüfe...";
|
||||
public string CreditsText { get; private set; } = "—";
|
||||
public string CreditsTooltip { get; private set; } = "";
|
||||
public double? CreditBalance { get; private set; }
|
||||
public double? CreditRemaining { get; private set; }
|
||||
|
||||
public event Action? OnStatusUpdated;
|
||||
|
||||
public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60)
|
||||
{
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
|
||||
|
||||
_interval = TimeSpan.FromSeconds(checkIntervalSeconds);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_loop ??= RunLoopAsync(_cts.Token);
|
||||
_ = CheckStatusAsync();
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
// CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier
|
||||
// muss nichts mehr behandelt werden.
|
||||
await CheckStatusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordUsage(string model, int promptTokens, int completionTokens)
|
||||
{
|
||||
var estimate = _pricing.Estimate(model, promptTokens, completionTokens);
|
||||
|
||||
if (!estimate.IsKnown)
|
||||
_modelsWithoutPricing.TryAdd(model, 0);
|
||||
|
||||
_usageRecords.Add(new UsageRecord(
|
||||
DateTime.Now, model, promptTokens, completionTokens, (double)estimate.Usd, estimate.IsKnown));
|
||||
|
||||
UpdateCreditsText();
|
||||
OnStatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und
|
||||
/// alle Kosten werden als unbekannt ausgewiesen.
|
||||
/// </summary>
|
||||
public async Task LoadPricingAsync(OpenRouterClient client, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var models = await client.GetAvailableModelsAsync(ct);
|
||||
_pricing.Load(models);
|
||||
|
||||
// Modelle, die bisher als unbekannt galten, sind jetzt vielleicht bekannt.
|
||||
foreach (var model in _modelsWithoutPricing.Keys)
|
||||
{
|
||||
if (_pricing.IsKnown(model))
|
||||
_modelsWithoutPricing.TryRemove(model, out _);
|
||||
}
|
||||
|
||||
UpdateCreditsText();
|
||||
OnStatusUpdated?.Invoke();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ohne Preise bleibt die Anzeige ehrlich unbekannt statt falsch null.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _http.GetAsync("auth/key");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(body);
|
||||
|
||||
IsApiReachable = true;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("data", out var data))
|
||||
{
|
||||
if (data.TryGetProperty("limit", out var limit))
|
||||
CreditBalance = limit.GetDouble();
|
||||
|
||||
if (data.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
var used = usage.GetDouble();
|
||||
var remaining = (CreditBalance ?? 0) - used;
|
||||
CreditRemaining = remaining;
|
||||
StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "✓ API erreichbar";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "✓ API erreichbar";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ API Fehler: {(int)response.StatusCode}";
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ Nicht erreichbar: {ex.Message}";
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = "✗ Timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsApiReachable = false;
|
||||
StatusText = $"✗ Fehler: {ex.Message}";
|
||||
}
|
||||
|
||||
UpdateCreditsText();
|
||||
OnStatusUpdated?.Invoke();
|
||||
}
|
||||
|
||||
private void UpdateCreditsText()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var oneHourAgo = now.AddHours(-1);
|
||||
var oneDayAgo = now.AddHours(-24);
|
||||
|
||||
var records = _usageRecords.ToArray();
|
||||
|
||||
var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray();
|
||||
var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray();
|
||||
|
||||
var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens);
|
||||
var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens);
|
||||
var costLastHour = lastHour.Sum(r => r.CostUsd);
|
||||
var costLast24h = last24h.Sum(r => r.CostUsd);
|
||||
|
||||
// Ein Hinweis, sobald Läufe dabei sind, deren Kosten nicht bezifferbar sind —
|
||||
// sonst liest sich eine zu niedrige Summe wie eine vollständige.
|
||||
var unpriced = last24h.Count(r => !r.CostIsKnown);
|
||||
var warning = unpriced > 0 ? " ⚠" : "";
|
||||
|
||||
CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " +
|
||||
$"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€){warning}";
|
||||
|
||||
// Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h)
|
||||
var modelGroups = last24h
|
||||
.GroupBy(r => r.Model)
|
||||
.OrderByDescending(g => g.Sum(r => r.CostUsd))
|
||||
.ToList();
|
||||
|
||||
if (modelGroups.Count == 0)
|
||||
{
|
||||
CreditsTooltip = "Keine Token-Nutzung in den letzten 24h";
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("═══ Token-Verbrauch (24h) ═══");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var group in modelGroups)
|
||||
{
|
||||
var modelName = group.Key;
|
||||
var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName;
|
||||
var prompt = group.Sum(r => r.PromptTokens);
|
||||
var completion = group.Sum(r => r.CompletionTokens);
|
||||
var total = prompt + completion;
|
||||
var cost = group.Sum(r => r.CostUsd);
|
||||
var runs = group.Count();
|
||||
|
||||
sb.AppendLine($"▸ {shortName}");
|
||||
sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)");
|
||||
|
||||
if (_pricing.Get(modelName) is { } pricing)
|
||||
{
|
||||
sb.AppendLine($" Preis: ${pricing.InputPer1M:0.####}/1M in, ${pricing.OutputPer1M:0.####}/1M out");
|
||||
sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" Kosten: unbekannt — für dieses Modell liegen keine Preise vor");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var totalCost = last24h.Sum(r => r.CostUsd);
|
||||
sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══");
|
||||
|
||||
if (unpriced > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"⚠ {unpriced} Lauf/Läufe ohne Preisangabe — die Summe ist unvollständig.");
|
||||
sb.AppendLine($" Betroffene Modelle: {string.Join(", ", _modelsWithoutPricing.Keys.Order())}");
|
||||
}
|
||||
|
||||
if (_pricing.LastUpdated is { } updated)
|
||||
sb.AppendLine($"\nPreise abgerufen: {updated:g} ({_pricing.Count} Modelle)");
|
||||
else
|
||||
sb.AppendLine("\n⚠ Preise noch nicht geladen.");
|
||||
|
||||
CreditsTooltip = sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
_http.Dispose();
|
||||
}
|
||||
|
||||
private sealed record UsageRecord(
|
||||
DateTime Timestamp,
|
||||
string Model,
|
||||
int PromptTokens,
|
||||
int CompletionTokens,
|
||||
double CostUsd,
|
||||
bool CostIsKnown);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Anwendungsweite Einstellungen (nicht instanzgebunden).
|
||||
///
|
||||
/// Die <see cref="CategoryAttribute"/>-, <see cref="DisplayNameAttribute"/>- und
|
||||
/// <see cref="DescriptionAttribute"/>-Angaben stammen aus der PropertyGrid-Zeit. Sie
|
||||
/// bleiben stehen: Sie sind die Beschriftungen und Hilfetexte, aus denen die
|
||||
/// Avalonia-Einstellungsansicht gebaut wird — nur eben von Hand statt automatisch.
|
||||
/// </summary>
|
||||
public sealed class AppSettings
|
||||
{
|
||||
// Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis.
|
||||
// Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht
|
||||
// schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde.
|
||||
// Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths).
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Log-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
|
||||
[JsonPropertyName("logDirectory")]
|
||||
public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs");
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Instanzen-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
|
||||
[JsonPropertyName("instancesDirectory")]
|
||||
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
|
||||
|
||||
// DefaultConfigPath ist ersatzlos entfallen. Die Eigenschaft war als „(Legacy)"
|
||||
// markiert und wurde von keiner Stelle mehr gelesen — sie stand nur noch als
|
||||
// relativer Pfad in der Datei und hätte unter Linux ohnehin ins Leere gezeigt.
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Minimaler Log-Level")]
|
||||
[Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
|
||||
[JsonPropertyName("minimumLogLevel")]
|
||||
public string MinimumLogLevel { get; set; } = "Info";
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Max. Log-Zeilen in UI")]
|
||||
[Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
|
||||
[JsonPropertyName("maxLogLinesInUi")]
|
||||
public int MaxLogLinesInUi { get; set; } = 2000;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Log-Aktualisierungsintervall (ms)")]
|
||||
[Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
|
||||
[JsonPropertyName("logRefreshIntervalMs")]
|
||||
public int LogRefreshIntervalMs { get; set; } = 500;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("Status-Check-Intervall (Sek)")]
|
||||
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
|
||||
[JsonPropertyName("statusCheckIntervalSeconds")]
|
||||
public int StatusCheckIntervalSeconds { get; set; } = 60;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("OpenRouter Base-URL")]
|
||||
[Description("Basis-URL der OpenRouter-API.")]
|
||||
[JsonPropertyName("openRouterBaseUrl")]
|
||||
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Backup-Verzeichnis")]
|
||||
[Description("Ordner, in dem Sicherungen abgelegt werden.")]
|
||||
[JsonPropertyName("backupDirectory")]
|
||||
public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups");
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Automatisch sichern")]
|
||||
[Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")]
|
||||
[JsonPropertyName("autoBackupEnabled")]
|
||||
public bool AutoBackupEnabled { get; set; }
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Uhrzeit der automatischen Sicherung")]
|
||||
[Description("Tageszeit im Format HH:mm.")]
|
||||
[JsonPropertyName("autoBackupTime")]
|
||||
public string AutoBackupTime { get; set; } = "03:00";
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Aufbewahrte Sicherungen")]
|
||||
[Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")]
|
||||
[JsonPropertyName("backupKeepCount")]
|
||||
public int BackupKeepCount { get; set; } = 14;
|
||||
|
||||
// ─── Deploymentcenter ───
|
||||
//
|
||||
// Ein Server, ein Token. Lizenz, Watchdog, Updates, Fehler-Stream und Bugtracker
|
||||
// sind Module derselben Anwendung — die frühere Aufteilung auf zwei Adressen
|
||||
// (watchdog.mhdf.de, license.mhdf.de) mit je eigenem Schlüssel gibt es nicht mehr.
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Server-URL")]
|
||||
[Description("Basis-URL des Deploymentcenters (nur HTTPS, Ausnahme localhost).")]
|
||||
[JsonPropertyName("deploymentcenterUrl")]
|
||||
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Token")]
|
||||
[Description("Master-Token mit den Rechten 'watchdog:ping' und 'bugtracker:report'. " +
|
||||
"Jede Instanz tauscht es beim ersten Start gegen ein eigenes, " +
|
||||
"eingeschränktes Sub-Token. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("deploymentcenterToken")]
|
||||
public string DeploymentcenterToken { get; set; } = "";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Umgebung")]
|
||||
[Description("Wird an Fehler- und Bugtracker-Meldungen gehängt: production oder development.")]
|
||||
[JsonPropertyName("deploymentcenterEnvironment")]
|
||||
public string DeploymentcenterEnvironment { get; set; } = "production";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Fehler automatisch melden")]
|
||||
[Description("Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. " +
|
||||
"Derselbe Fehler geht höchstens alle fünf Minuten einmal raus.")]
|
||||
[JsonPropertyName("errorReportingEnabled")]
|
||||
public bool ErrorReportingEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Beim Start auf Updates prüfen")]
|
||||
[Description("Fragt einmalig beim Start, ob ein neueres Release vorliegt. Blockiert nicht.")]
|
||||
[JsonPropertyName("updateCheckEnabled")]
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Update-Kanal")]
|
||||
[Description("prod, beta oder dev.")]
|
||||
[JsonPropertyName("updateChannel")]
|
||||
public string UpdateChannel { get; set; } = "prod";
|
||||
|
||||
// ─── Lizenz ───
|
||||
|
||||
[Category("Lizenz")]
|
||||
[DisplayName("Lizenzschlüssel")]
|
||||
[Description("Der Lizenzschlüssel für ClawdDotNet. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("licenseKey")]
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
// LicensePublicKeyBase64 und LicenseEndpoints sind entfallen. Das Deploymentcenter
|
||||
// signiert seine Antworten nicht (siehe LicenseInfo), ein Public-Key hätte also
|
||||
// nichts zu prüfen; und der Lizenzserver ist dasselbe Deploymentcenter, dessen
|
||||
// Adresse oben steht — zwei Felder für eine Adresse waren nur eine Gelegenheit,
|
||||
// sie widersprüchlich zu füllen.
|
||||
|
||||
public override string ToString() => "Anwendungseinstellungen";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt und speichert die anwendungsweiten Einstellungen.
|
||||
///
|
||||
/// <para><b>Ablageort.</b> Bis zur Linux-Portierung lag <c>Settings.json</c> neben der
|
||||
/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt
|
||||
/// die Anwendung in <c>/opt</c> oder <c>/usr/lib</c> und ist für den Dienstbenutzer nicht
|
||||
/// beschreibbar. Jetzt entscheidet <see cref="AppPaths.ConfigDirectory"/> — XDG unter
|
||||
/// Linux, <c>%APPDATA%</c> unter Windows, per <c>CLAWD_CONFIG_DIR</c> überschreibbar.</para>
|
||||
///
|
||||
/// <para><b>Kein Migrationspfad.</b> Bewusst: Zum Zeitpunkt der Umstellung lief noch
|
||||
/// keine Installation produktiv. Eine bestehende <c>Settings.json</c> neben der
|
||||
/// Programmdatei wird also <em>nicht</em> übernommen — der Ort wechselt einmal sauber,
|
||||
/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst.</para>
|
||||
///
|
||||
/// <para><b>Keine Meldungsfenster.</b> Diese Schicht kennt keine Oberfläche. Ein
|
||||
/// Speicherfehler kommt als <see cref="SettingsPersistenceException"/> heraus; ob daraus
|
||||
/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer.</para>
|
||||
/// </summary>
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public AppSettings AppSettings { get; private set; } = new();
|
||||
|
||||
/// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary>
|
||||
public string SettingsPath => _settingsPath;
|
||||
|
||||
public SettingsManager(string? basePath = null)
|
||||
{
|
||||
var dir = basePath ?? AppPaths.ConfigDirectory;
|
||||
_settingsPath = Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = AtomicFile.ReadAllText(_settingsPath);
|
||||
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? new AppSettings();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
// Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben
|
||||
// weiterzumachen ist besser, als gar nicht zu starten.
|
||||
AppSettings = new AppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im
|
||||
// Klartext gehalten.
|
||||
AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey);
|
||||
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
|
||||
/// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen
|
||||
/// unbrauchbaren Wert an eine Gegenstelle zu schicken.
|
||||
/// </summary>
|
||||
private static string TryUnprotect(string value)
|
||||
{
|
||||
try { return SecretProtector.Unprotect(value) ?? ""; }
|
||||
catch (SecretProtectionException) { return ""; }
|
||||
}
|
||||
|
||||
/// <exception cref="SettingsPersistenceException">Wenn die Datei nicht geschrieben werden kann.</exception>
|
||||
public void Save()
|
||||
{
|
||||
// Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext.
|
||||
var plainLicenseKey = AppSettings.LicenseKey;
|
||||
var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken;
|
||||
|
||||
try
|
||||
{
|
||||
AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? "";
|
||||
AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? "";
|
||||
|
||||
AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!);
|
||||
|
||||
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
|
||||
AtomicFile.WriteAllText(_settingsPath, json);
|
||||
AppPaths.RestrictToOwner(_settingsPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or SecretProtectionException)
|
||||
{
|
||||
throw new SettingsPersistenceException(
|
||||
$"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppSettings.LicenseKey = plainLicenseKey;
|
||||
AppSettings.DeploymentcenterToken = plainDeploymentcenterToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SettingsPersistenceException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
Reference in New Issue
Block a user