feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
+485
View File
@@ -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>&lt;Version&gt;</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();
}
}