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
@@ -1,143 +0,0 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Scheduling;
public sealed class AgentScheduler : IAsyncDisposable
{
private readonly AgentEngine _engine;
private readonly string _instanceId;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _schedulerTasks = new();
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
private readonly Lock _resultsLock = new();
public event Action<string, AgentRunResult>? OnRunCompleted;
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
{
_engine = engine;
_instanceId = instanceId;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
}
public void RegisterAgent(AgentConfig agentConfig)
{
if (agentConfig.Scheduler is null)
return;
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
_schedulerTasks.Add(task);
}
public void RegisterAll(IEnumerable<AgentConfig> agents)
{
foreach (var agent in agents)
RegisterAgent(agent);
}
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
{
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
return result;
}
public AgentRunResult? GetLastResult(string agentId)
{
lock (_resultsLock)
return _lastResults.GetValueOrDefault(agentId);
}
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
{
var scheduler = agentConfig.Scheduler!;
if (scheduler.RunOnStart)
{
await ExecuteScheduledRunAsync(agentConfig, ct);
}
if (string.IsNullOrWhiteSpace(scheduler.Cron))
return;
var cron = CronExpression.Parse(scheduler.Cron);
while (!ct.IsCancellationRequested)
{
var now = DateTime.Now;
var next = cron.GetNextOccurrence(now);
if (next is null)
{
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
return;
}
var delay = next.Value - now;
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}
await ExecuteScheduledRunAsync(agentConfig, ct);
}
}
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
{
try
{
var result = await _engine.RunAsync(
agentConfig,
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
_instanceId,
ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
_logger.LogInformation(
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
agentConfig.AgentId, result.Status, result.TokensUsed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
}
}
private void StoreResult(string agentId, AgentRunResult result)
{
lock (_resultsLock)
_lastResults[agentId] = result;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await Task.WhenAll(_schedulerTasks);
}
catch (OperationCanceledException)
{
}
_cts.Dispose();
}
}
@@ -90,6 +90,9 @@ public sealed class CronExpression
foreach (var part in field.Split(','))
{
if (part.Length == 0)
throw Bad(field, "leeres Teilfeld");
if (part == "*")
{
for (var i = min; i <= max; i++) result.Add(i);
@@ -97,23 +100,44 @@ public sealed class CronExpression
else if (part.Contains('/'))
{
var split = part.Split('/');
var start = split[0] == "*" ? min : int.Parse(split[0]);
var step = int.Parse(split[1]);
if (split.Length != 2)
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
for (var i = start; i <= max; i += step) result.Add(i);
}
else if (part.Contains('-'))
{
var split = part.Split('-');
var from = int.Parse(split[0]);
var to = int.Parse(split[1]);
if (split.Length != 2)
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
var from = ParseNumber(field, split[0], min, max);
var to = ParseNumber(field, split[1], min, max);
if (from > to)
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
for (var i = from; i <= to; i++) result.Add(i);
}
else
{
result.Add(int.Parse(part));
result.Add(ParseNumber(field, part, min, max));
}
}
return result;
}
private static int ParseNumber(string field, string value, int min, int max)
{
if (!int.TryParse(value, out var n))
throw Bad(field, $"'{value}' ist keine Zahl");
if (n < min || n > max)
throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
return n;
}
private static FormatException Bad(string field, string reason)
=> new($"Ungültiges Cron-Feld '{field}': {reason}.");
}
@@ -0,0 +1,96 @@
namespace ClawdDotNet.Core.Scheduling;
/// <summary>
/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie
/// geschrieben wurden.
///
/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux
/// unterschiedlich benannt — <c>"W. Europe Standard Time"</c> gegen
/// <c>"Europe/Berlin"</c>. Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis
/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist,
/// muss auf dem anderen lesbar bleiben.
///
/// Zuvor fing <c>TaskSchedule</c> die unbekannte Kennung ab und rechnete <b>still</b>
/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer
/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten,
/// und was sich nicht deuten lässt, meldet <see cref="TryResolve"/> als <c>null</c>
/// zurück, statt es zu erraten.
///
/// <para><b>Voraussetzung auf dem Zielsystem:</b> Die Umsetzung zwischen beiden
/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus <c>tzdata</c>. In
/// einem schlanken Abbild (Alpine ohne <c>icu-libs</c>, distroless) oder bei
/// <c>InvariantGlobalization=true</c> fehlen sie — dann schlägt jede Auflösung außer
/// UTC fehl. Beides gehört ins Abbild.</para>
/// </summary>
public static class TimeZones
{
/// <summary>
/// Die lokale Zeitzone in IANA-Schreibweise (<c>Europe/Berlin</c>).
///
/// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf
/// Linux, macOS und — seit .NET 8 — auch auf Windows.
/// </summary>
public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id);
/// <summary>
/// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise.
/// Gibt <c>null</c> zurück, wenn sie auf diesem System nicht auflösbar ist —
/// der Aufrufer muss dann entscheiden, und zwar sichtbar.
/// </summary>
public static TimeZoneInfo? TryResolve(string? id)
{
if (string.IsNullOrWhiteSpace(id)) return null;
var value = id.Trim();
if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase))
return TimeZoneInfo.Utc;
// Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide
// Formen an. Wenn das reicht, sind wir fertig.
try { return TimeZoneInfo.FindSystemTimeZoneById(value); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
// Sonst die jeweils andere Schreibweise versuchen.
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana))
{
try { return TimeZoneInfo.FindSystemTimeZoneById(iana); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
}
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows))
{
try { return TimeZoneInfo.FindSystemTimeZoneById(windows); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
}
return null;
}
/// <summary>
/// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das
/// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet.
/// </summary>
public static bool IsKnown(string? id)
=> string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null;
/// <summary>
/// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt
/// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer,
/// als sie durchzureichen.
/// </summary>
public static string ToIana(string? id)
{
if (string.IsNullOrWhiteSpace(id)) return "";
var value = id.Trim();
// Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen).
if (value.Contains('/')) return value;
return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value;
}
}
@@ -1,213 +0,0 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Scheduling;
public sealed class ToolJobScheduler : IAsyncDisposable
{
private readonly AgentEngine _engine;
private readonly ToolRegistry _toolRegistry;
private readonly IStateStore _stateStore;
private readonly string _instanceId;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _schedulerTasks = new();
private readonly Dictionary<string, ToolJobResult?> _lastResults = new();
private readonly Lock _resultsLock = new();
public event Action<string, string, ToolJobResult>? OnJobTick;
public ToolJobScheduler(
AgentEngine engine,
ToolRegistry toolRegistry,
IStateStore stateStore,
ILoggerFactory loggerFactory,
string instanceId)
{
_engine = engine;
_toolRegistry = toolRegistry;
_stateStore = stateStore;
_instanceId = instanceId;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
}
public void RegisterAll(IEnumerable<AgentConfig> agents)
{
foreach (var agent in agents)
{
foreach (var jobConfig in agent.ToolJobs)
{
if (!jobConfig.Enabled)
continue;
var tool = _toolRegistry.Get(jobConfig.ToolName);
if (tool is not IToolJobProvider provider)
{
_logger.LogWarning(
"Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
continue;
}
_logger.LogInformation(
"Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
_schedulerTasks.Add(task);
}
}
}
public ToolJobResult? GetLastResult(string jobId)
{
lock (_resultsLock)
return _lastResults.GetValueOrDefault(jobId);
}
/// <summary>
/// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
/// </summary>
public async Task<ToolJobResult> TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
{
var tool = _toolRegistry.Get(jobConfig.ToolName);
if (tool is not IToolJobProvider provider)
return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
_logger.LogInformation(
"Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
lock (_resultsLock)
return _lastResults.GetValueOrDefault(jobConfig.JobId)
?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
}
private async Task RunToolJobAsync(
AgentConfig agentConfig,
ToolJobConfig jobConfig,
IToolJobProvider provider,
CancellationToken ct)
{
if (jobConfig.RunOnStart)
{
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
}
if (string.IsNullOrWhiteSpace(jobConfig.Cron))
return;
var cron = CronExpression.Parse(jobConfig.Cron);
while (!ct.IsCancellationRequested && jobConfig.Enabled)
{
var now = DateTime.Now;
var next = cron.GetNextOccurrence(now);
if (next is null)
{
_logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
return;
}
var delay = next.Value - now;
_logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
jobConfig.JobId, jobConfig.JobTypeId, next.Value);
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
}
}
private async Task ExecuteTickAsync(
AgentConfig agentConfig,
ToolJobConfig jobConfig,
IToolJobProvider provider,
CancellationToken ct)
{
var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
{
_logger.LogWarning(
"Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
jobConfig.Enabled = false;
var disabledResult = new ToolJobResult(false, null,
$"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
lock (_resultsLock)
_lastResults[jobConfig.JobId] = disabledResult;
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
return;
}
try
{
var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var result = await provider.ExecuteJobAsync(
jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
agentConfig.AgentId, agentConfig.WorkspacePath);
lock (_resultsLock)
_lastResults[jobConfig.JobId] = result;
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
_logger.LogInformation(
"Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
{
_logger.LogInformation(
"Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
if (result.UseChatContext)
await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
else
await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
agentConfig.AgentId, jobConfig.JobId);
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await Task.WhenAll(_schedulerTasks);
}
catch (OperationCanceledException)
{
}
_cts.Dispose();
}
}