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
+17
View File
@@ -0,0 +1,17 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:ClawdDotNet.Desktop"
x:Class="ClawdDotNet.Desktop.App"
RequestedThemeVariant="Default">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
</Application.Styles>
</Application>
+213
View File
@@ -0,0 +1,213 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Desktop.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
namespace ClawdDotNet.Desktop;
public partial class App : Application
{
private AppHost? _host;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster
// ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung
// in genau dieser Lücke aussteigen.
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
desktop.ShutdownRequested += async (_, _) =>
{
if (_host is not null) await _host.DisposeAsync();
};
// Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren,
// damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf
// dem die Fenster des Startvorgangs überhaupt erscheinen könnten.
_ = StartAsync(desktop);
}
base.OnFrameworkInitializationCompleted();
}
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
var result = await AppHost.StartAsync(new AppHost.Callbacks
{
SelectInstance = SelectInstanceAsync,
License = new AvaloniaLicensePrompt(),
TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt),
Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:")
});
if (result.Error is { } error)
{
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error);
desktop.Shutdown(1);
return;
}
if (result.Host is null)
{
// Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits
// erklärt worden, hier kommt keine weitere Meldung hinterher.
desktop.Shutdown();
return;
}
_host = result.Host;
HookErrorReporting(_host);
HookLicenseWatch(_host, desktop);
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(_host)
};
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
await ShowUpdateNoticeAsync(_host);
}
/// <summary>
/// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters.
///
/// <para>Erst hier verdrahtet, nicht in <c>Main</c>: Vor dem Aufbau gibt es weder
/// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf.
/// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz <em>während</em> des
/// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.</para>
/// </summary>
private static void HookErrorReporting(AppHost host)
{
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
if (args.ExceptionObject is Exception ex)
{
// Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen.
host.Errors.ReportAsync(ex, fatal: args.IsTerminating)
.Wait(TimeSpan.FromSeconds(3));
}
};
TaskScheduler.UnobservedTaskException += (_, args) =>
{
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
// Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen
// den Prozess mit — und das wäre eine Nebenwirkung des Meldens.
args.SetObserved();
};
Dispatcher.UIThread.UnhandledException += (_, args) =>
{
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
// Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden.
args.Handled = true;
};
}
/// <summary>Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage.</summary>
private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
{
if (host.LicenseWatch is null) return;
host.LicenseWatch.Revoked += async message =>
{
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2));
};
}
/// <summary>
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert
/// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst
/// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht.
/// </summary>
private static async Task ShowUpdateNoticeAsync(AppHost host)
{
if (host.Deploymentcenter is null) return;
// Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben.
for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++)
await Task.Delay(TimeSpan.FromSeconds(1));
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
await new AvaloniaLicensePrompt().ShowInfoAsync(
update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
$"Version {update.LatestVersion} ist verfügbar."
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
}
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var viewModel = new InstancePickerViewModel(directories);
var window = new InstancePickerWindow { DataContext = viewModel };
// Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete
// der Start für immer auf eine Auswahl, die nie kommt.
window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
window.Show();
var path = await viewModel.Result;
window.Close();
return path;
});
/// <summary>
/// Einzeilige Abfrage — ersetzt <c>Microsoft.VisualBasic.Interaction.InputBox</c>,
/// das die Telegram-Anmeldung an Windows band.
/// </summary>
private static async Task<string> AskAsync(string title, string prompt)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<string>();
var input = new TextBox();
var ok = new Button { Content = "OK", IsDefault = true };
var window = new Window
{
Title = title,
Width = 420,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 12,
Children =
{
new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
input,
ok
}
}
};
ok.Click += (_, _) => window.Close();
window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? "");
window.Show();
input.Focus();
return await completion.Task;
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Desktop</RootNamespace>
<AssemblyName>ClawdDotNet</AssemblyName>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<!-- Avalonia legt AXAML-Dateien selbst als AvaloniaResource an; die Standard-Globs
wuerden sie zusaetzlich als None einsammeln. -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Kein Konsolenfenster unter Windows, aber unter Linux ein normaler Prozess.
WinExe verhaelt sich dort ohnehin wie Exe. -->
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,65 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Layout;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
/// </summary>
public sealed class ChatBubbleBrushConverter : IValueConverter
{
public static readonly ChatBubbleBrushConverter Instance = new();
private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isUser && isUser)
return UserBubble;
return null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
/// </summary>
public sealed class ChatAlignmentConverter : IValueConverter
{
public static readonly ChatAlignmentConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isUser && isUser)
return HorizontalAlignment.Right;
return HorizontalAlignment.Left;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
/// </summary>
public sealed class ToolMatchConverter : IValueConverter
{
public static readonly ToolMatchConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is string toolName && parameter is string targetTool)
return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -0,0 +1,33 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Färbt eine Logzeile nach ihrer Stufe.
///
/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
/// </summary>
public sealed class LogLevelBrushConverter : IValueConverter
{
public static readonly LogLevelBrushConverter Instance = new();
private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value as string switch
{
"ERR" or "FTL" => Error,
"WRN" => Warn,
// null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
// Farbe aus dem Thema.
_ => null
};
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
+33
View File
@@ -0,0 +1,33 @@
using Avalonia;
namespace ClawdDotNet.Desktop;
internal static class Program
{
/// <summary>
/// Einstiegspunkt der Oberfläche.
///
/// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche —
/// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut
/// <see cref="App"/> über die Anwendungsschicht auf, damit derselbe Aufbau später
/// auch ohne Fenster laufen kann.
///
/// Kein <c>[STAThread]</c> mehr: Das war eine COM-Anforderung von WinForms. Avalonia
/// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung.
/// </summary>
public static int Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
/// <summary>
/// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und
/// getrennt von <see cref="Main"/>.
/// </summary>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
// Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
// Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
// Oberfläche leer.
.WithInterFont()
.LogToTrace();
}
@@ -0,0 +1,85 @@
using Avalonia.Controls;
using Avalonia.Threading;
using ClawdDotNet.App.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
namespace ClawdDotNet.Desktop.Services;
/// <summary>
/// Lizenzabfrage über Fenster.
///
/// Der Aufbau läuft auf einem Hintergrundfaden — Avalonia besteht aber darauf, dass
/// Fenster auf dem Oberflächenfaden entstehen. Deshalb geht hier alles über
/// <see cref="Dispatcher.UIThread"/>. Das ist der Grund, warum
/// <see cref="ILicensePrompt"/> durchgehend asynchron ist: Die WinForms-Fassung konnte
/// <c>ShowDialog</c> einfach blockierend aufrufen, weil sie ohnehin auf dem
/// Oberflächenfaden lief.
/// </summary>
public sealed class AvaloniaLicensePrompt : ILicensePrompt
{
public async Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var viewModel = new LicenseViewModel(hardwareId, problem, currentKey);
var window = new LicenseWindow { DataContext = viewModel };
window.Show();
var key = await viewModel.Result;
window.Close();
return key;
});
public Task ShowInfoAsync(string title, string message)
=> ShowMessageAsync(title, message);
public Task ShowErrorAsync(string title, string message)
=> ShowMessageAsync(title, message);
/// <summary>
/// Ein Meldungsfenster.
///
/// Avalonia bringt kein <c>MessageBox</c> mit — bewusst, weil es auf allen
/// Plattformen anders aussähe. Ein schlichtes Fenster ist hier ausreichend und
/// erspart uns eine weitere Abhängigkeit für drei Aufrufstellen.
/// </summary>
private static async Task ShowMessageAsync(string title, string message)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource();
var button = new Button
{
Content = "OK",
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
IsDefault = true
};
var window = new Window
{
Title = title,
Width = 460,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Avalonia.Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
button
}
}
};
button.Click += (_, _) => window.Close();
window.Closed += (_, _) => completion.TrySetResult();
window.Show();
await completion.Task;
});
}
@@ -0,0 +1,44 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Gemeinsame Anmutung der Oberflaeche.
Bewusst wenige, benannte Regeln statt Formatierung an jedem Steuerelement: Die
WinForms-Fassung hatte Groessen, Abstaende und Schriften ueber 130 Stellen in den
Designer-Dateien verteilt: Jede Aenderung war eine Suche.
-->
<Style Selector="TextBlock.heading">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style Selector="TextBlock.caption">
<Setter Property="Opacity" Value="0.7" />
<Setter Property="FontSize" Value="12" />
</Style>
<!-- Werkzeugleiste ueber einer Ansicht -->
<Style Selector="StackPanel.toolbar">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Spacing" Value="6" />
<Setter Property="Margin" Value="8" />
</Style>
<Style Selector="Border.card">
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12" />
</Style>
<!-- Statusleiste am unteren Rand -->
<Style Selector="Border.statusbar">
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="8,4" />
</Style>
</Styles>
+40
View File
@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop;
/// <summary>
/// Findet zu einem Ansichtsmodell die passende Ansicht über die Namenskonvention
/// <c>…ViewModels.FooViewModel</c> → <c>…Views.FooView</c>.
///
/// Das erspart es, jede Zuordnung von Hand als <c>DataTemplate</c> einzutragen — bei
/// den sieben Bereichen und ihren Unteransichten wären das schnell dreißig Einträge,
/// die man beim Umbenennen übersieht.
///
/// Passt nichts, erscheint der gesuchte Typname im Fenster statt einer leeren Fläche.
/// Beim schrittweisen Umbau ist eine sichtbare Fehlstelle mehr wert als ein stiller
/// weißer Bereich.
/// </summary>
public sealed class ViewLocator : IDataTemplate
{
public Control Build(object? data)
{
if (data is null)
return new TextBlock { Text = "(kein Inhalt)" };
var viewModelName = data.GetType().FullName!;
var viewName = viewModelName
.Replace(".ViewModels.", ".Views.", StringComparison.Ordinal)
.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(viewName);
if (type is null)
return new TextBlock { Text = $"Ansicht nicht gefunden: {viewName}" };
return (Control)Activator.CreateInstance(type)!;
}
public bool Match(object? data) => data is ViewModelBase;
}
@@ -0,0 +1,76 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddAgentResultData(
string AgentId,
string DisplayName,
string Model,
string Description
);
public sealed partial class AddAgentViewModel : ViewModelBase
{
[ObservableProperty]
private string _agentId = "";
[ObservableProperty]
private string _displayName = "";
[ObservableProperty]
private string _selectedModel = "anthropic/claude-3-5-sonnet";
[ObservableProperty]
private string _description = "";
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableModels { get; } =
[
"anthropic/claude-3-5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1"
];
public AddAgentResultData? Result { get; private set; }
public event Action? CloseRequested;
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(AgentId))
{
StatusMessage = "Bitte eine Agenten-ID eingeben.";
return;
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
StatusMessage = "Bitte einen Anzeigenamen eingeben.";
return;
}
Result = new AddAgentResultData(
AgentId.Trim(),
DisplayName.Trim(),
SelectedModel,
Description.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,111 @@
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddJobResultData(
string AgentId,
bool IsToolJob,
string ToolName,
string JobTypeId,
string CronExpression,
string TaskMessage,
bool RunOnStart
);
public sealed partial class AddJobViewModel : ViewModelBase
{
public List<AgentConfig> AvailableAgents { get; }
[ObservableProperty]
private AgentConfig? _selectedAgent;
[ObservableProperty]
private bool _isToolJob;
[ObservableProperty]
private bool _isAgentWakeup = true;
[ObservableProperty]
private string _selectedToolName = "FileRW";
[ObservableProperty]
private string _jobTypeId = "PollJob";
[ObservableProperty]
private string _cronExpression = "0 * * * *";
[ObservableProperty]
private string _taskMessage = "Führe deine zugewiesenen Aufgaben aus.";
[ObservableProperty]
private bool _runOnStart;
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableTools { get; } =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AddJobResultData? Result { get; private set; }
public event Action? CloseRequested;
public AddJobViewModel() : this([]) { }
public AddJobViewModel(List<AgentConfig> agents)
{
AvailableAgents = agents;
SelectedAgent = AvailableAgents.FirstOrDefault();
}
partial void OnIsToolJobChanged(bool value)
{
IsAgentWakeup = !value;
}
partial void OnIsAgentWakeupChanged(bool value)
{
IsToolJob = !value;
}
[RelayCommand]
private void Confirm()
{
if (SelectedAgent is null)
{
StatusMessage = "Bitte einen Agenten auswählen.";
return;
}
if (string.IsNullOrWhiteSpace(CronExpression))
{
StatusMessage = "Bitte einen Cron-Ausdruck angeben.";
return;
}
Result = new AddJobResultData(
SelectedAgent.AgentId,
IsToolJob,
SelectedToolName,
JobTypeId,
CronExpression.Trim(),
TaskMessage.Trim(),
RunOnStart
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,67 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddServiceResultData(
string ServiceName,
string ServiceType,
int ServicePort,
string ServiceDescription
);
public sealed partial class AddServiceViewModel : ViewModelBase
{
[ObservableProperty]
private string _serviceName = "";
[ObservableProperty]
private string _serviceType = "HTTP";
[ObservableProperty]
private int _servicePort = 8080;
[ObservableProperty]
private string _serviceDescription = "";
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableServiceTypes { get; } = ["HTTP", "TCP", "gRPC", "WebSocket", "Custom"];
public AddServiceResultData? Result { get; private set; }
public event Action? CloseRequested;
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(ServiceName))
{
StatusMessage = "Bitte einen Dienstnamen eingeben.";
return;
}
if (ServicePort <= 0 || ServicePort > 65535)
{
StatusMessage = "Gültigen Port zwischen 1 und 65535 eingeben.";
return;
}
Result = new AddServiceResultData(
ServiceName.Trim(),
ServiceType,
ServicePort,
ServiceDescription.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,280 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolItemViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty]
private bool _isEnabled;
public ToolItemViewModel(string name, bool isEnabled)
{
Name = name;
_isEnabled = isEnabled;
}
}
public sealed partial class AgentItemViewModel : ObservableObject
{
public AgentConfig Config { get; }
public string AgentId => Config.AgentId;
[ObservableProperty]
private string _displayName;
[ObservableProperty]
private string _model;
[ObservableProperty]
private string _systemPrompt;
[ObservableProperty]
private string _identity;
[ObservableProperty]
private string _soul;
[ObservableProperty]
private string _description;
[ObservableProperty]
private string _promptCaching;
[ObservableProperty]
private int _maxToolResultChars;
[ObservableProperty]
private int _maxSteps;
[ObservableProperty]
private int _maxContextTokens;
[ObservableProperty]
private int _timeoutSeconds;
[ObservableProperty]
private decimal _dailyCostUsd;
[ObservableProperty]
private long _dailyTokens;
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
public static readonly List<string> KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AgentItemViewModel(AgentConfig config)
{
Config = config;
_displayName = config.DisplayName;
_model = config.Model;
_systemPrompt = config.SystemPrompt;
_identity = config.Identity;
_soul = config.Soul;
_description = config.Description;
_promptCaching = config.PromptCaching;
_maxToolResultChars = config.MaxToolResultChars;
_maxSteps = config.LoopGuard.MaxSteps;
_maxContextTokens = config.LoopGuard.MaxContextTokens;
_timeoutSeconds = config.LoopGuard.TimeoutSeconds;
_dailyCostUsd = config.Budget.DailyCostUsd;
_dailyTokens = config.Budget.DailyTokens;
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var item = new ToolItemViewModel(tool, isEnabled);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
{
if (item.IsEnabled && !Config.Tools.ContainsKey(tool))
Config.Tools[tool] = new Dictionary<string, object?>();
else if (!item.IsEnabled)
Config.Tools.Remove(tool);
}
};
AvailableTools.Add(item);
}
}
public void ApplyChanges()
{
Config.DisplayName = DisplayName;
Config.Model = Model;
Config.SystemPrompt = SystemPrompt;
Config.Identity = Identity;
Config.Soul = Soul;
Config.Description = Description;
Config.PromptCaching = PromptCaching;
Config.MaxToolResultChars = MaxToolResultChars;
Config.LoopGuard.MaxSteps = MaxSteps;
Config.LoopGuard.MaxContextTokens = MaxContextTokens;
Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
Config.Budget.DailyCostUsd = DailyCostUsd;
Config.Budget.DailyTokens = DailyTokens;
}
}
/// <summary>
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
/// </summary>
public sealed partial class AgentsPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
public ObservableCollection<AgentItemViewModel> Agents { get; } = [];
[ObservableProperty]
private AgentItemViewModel? _selectedAgent;
[ObservableProperty]
private string _statusText = "Bereit";
public List<string> KnownModels { get; } =
[
"anthropic/claude-3-5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1"
];
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
public event Func<ToolSettingsViewModel, Task<Dictionary<string, object?>?>>? RequestToolSettingsDialog;
public AgentsPageViewModel() : this(null) { }
public AgentsPageViewModel(AppHost? host) : base("Agenten")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
}
else
{
StatusText = "Entwurfsmodus";
}
}
[RelayCommand]
private void ReloadAgents()
{
Agents.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
Agents.Add(new AgentItemViewModel(agent));
}
SelectedAgent = Agents.FirstOrDefault();
}
[RelayCommand]
private async Task AddAgentAsync()
{
if (_host is null || RequestAddAgentDialog is null) return;
var vm = new AddAgentViewModel();
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase)))
{
StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits.";
return;
}
var newConfig = new AgentConfig
{
AgentId = result.AgentId,
DisplayName = result.DisplayName,
Model = result.Model,
Description = result.Description
};
_host.Instance.Agents.Add(newConfig);
SaveAgents();
var item = new AgentItemViewModel(newConfig);
Agents.Add(item);
SelectedAgent = item;
StatusText = $"Agent '{result.DisplayName}' angelegt.";
}
[RelayCommand]
private void RemoveAgent()
{
if (_host is null || SelectedAgent is null) return;
var agentId = SelectedAgent.AgentId;
_host.Instance.Agents.RemoveAll(a => a.AgentId == agentId);
SaveAgents();
Agents.Remove(SelectedAgent);
SelectedAgent = Agents.FirstOrDefault();
StatusText = $"Agent '{agentId}' entfernt.";
}
[RelayCommand]
private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
{
if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
var toolName = toolItem.Name;
if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
{
config = new Dictionary<string, object?>();
SelectedAgent.Config.Tools[toolName] = config;
toolItem.IsEnabled = true;
}
var vm = new ToolSettingsViewModel(toolName, config);
var newConfig = await RequestToolSettingsDialog(vm);
if (newConfig is not null)
{
SelectedAgent.Config.Tools[toolName] = newConfig;
SaveAgents();
StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
}
}
[RelayCommand]
private void SaveAgents()
{
if (_host is null) return;
foreach (var agentVm in Agents)
{
agentVm.ApplyChanges();
}
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
StatusText = "Agenten-Konfiguration gespeichert.";
}
}
@@ -0,0 +1,397 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record BackupItemViewModel(
string FileName,
DateTime LastWriteTime,
string InstanceName,
long SizeBytes,
string SizeFormatted,
bool HasSecrets,
string SecretsInfo,
string FullPath,
bool IsReadable
);
/// <summary>
/// Ansichtsmodell für die Sicherungs- und Wiederherstellungsseite.
/// </summary>
public sealed partial class BackupPageViewModel : PageViewModel
{
private readonly BackupService _service = new();
private readonly AppHost? _host;
private readonly SettingsManager? _settingsManager;
private readonly ILogger? _logger;
private readonly string _instanceDir;
private readonly string _instanceName;
private bool _loading;
[ObservableProperty]
private string _targetFolder = "";
[ObservableProperty]
private bool _secretsExclude = true;
[ObservableProperty]
private bool _secretsPassphrase;
[ObservableProperty]
private string _passphrase = "";
[ObservableProperty]
private string _passphraseRepeat = "";
[ObservableProperty]
private bool _includeLogs;
[ObservableProperty]
private bool _includeChatHistory = true;
[ObservableProperty]
private bool _autoBackupEnabled;
[ObservableProperty]
private string _autoBackupTime = "03:00";
[ObservableProperty]
private int _keepCount = 10;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private BackupItemViewModel? _selectedBackup;
public ObservableCollection<BackupItemViewModel> Backups { get; } = [];
public event Func<RestoreBackupViewModel, Task<RestoreResultData?>>? RequestRestoreDialog;
public BackupPageViewModel() : this(null) { }
public BackupPageViewModel(AppHost? host) : base("Sicherung")
{
_host = host;
if (host is not null)
{
_instanceDir = host.InstancePath;
_instanceName = host.Instance.InstanceName;
_settingsManager = host.Settings;
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup");
LoadSettings();
RefreshBackupList();
}
else
{
_instanceDir = "";
_instanceName = "Entwurf";
TargetFolder = @"C:\ClawdDotNet\Backups";
}
}
partial void OnAutoBackupEnabledChanged(bool value) => SaveSettings();
partial void OnAutoBackupTimeChanged(string value) => SaveSettings();
partial void OnKeepCountChanged(int value) => SaveSettings();
private void LoadSettings()
{
if (_settingsManager is null) return;
_loading = true;
try
{
var settings = _settingsManager.AppSettings;
TargetFolder = Path.GetFullPath(settings.BackupDirectory);
AutoBackupEnabled = settings.AutoBackupEnabled;
KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999);
AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time)
? time.ToString(@"hh\:mm")
: "03:00";
}
finally
{
_loading = false;
}
}
public void SaveSettings()
{
if (_loading || _settingsManager is null) return;
var settings = _settingsManager.AppSettings;
settings.BackupDirectory = TargetFolder.Trim();
settings.AutoBackupEnabled = AutoBackupEnabled;
settings.AutoBackupTime = AutoBackupTime.Trim();
settings.BackupKeepCount = KeepCount;
_settingsManager.Save();
}
[RelayCommand]
private void RefreshBackupList()
{
Backups.Clear();
var folder = TargetFolder.Trim();
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return;
Task.Run(async () =>
{
var files = new DirectoryInfo(folder)
.GetFiles("*.zip")
.OrderByDescending(f => f.LastWriteTime)
.ToList();
var list = new List<BackupItemViewModel>();
foreach (var file in files)
{
try
{
var manifest = await _service.InspectAsync(file.FullName);
var secretsInfo = manifest.HasSecrets
? $"ja ({manifest.SecretCount}, Passphrase)"
: "nein";
list.Add(new BackupItemViewModel(
file.Name, file.LastWriteTime, manifest.InstanceName,
file.Length, FormatSize(file.Length), manifest.HasSecrets, secretsInfo,
file.FullName, true));
}
catch
{
list.Add(new BackupItemViewModel(
file.Name, file.LastWriteTime, "—",
file.Length, FormatSize(file.Length), false, "unlesbar",
file.FullName, false));
}
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
Backups.Clear();
foreach (var item in list)
Backups.Add(item);
});
});
}
[RelayCommand]
private async Task CreateBackupAsync()
{
if (string.IsNullOrWhiteSpace(_instanceDir))
{
StatusText = "Keine gültige Instanz geladen.";
return;
}
var folder = TargetFolder.Trim();
if (string.IsNullOrWhiteSpace(folder))
{
StatusText = "Bitte einen Zielordner angeben.";
return;
}
BackupOptions options;
if (SecretsPassphrase)
{
if (string.IsNullOrEmpty(Passphrase))
{
StatusText = "Bitte eine Passphrase eingeben.";
return;
}
if (Passphrase != PassphraseRepeat)
{
StatusText = "Die Passphrasen stimmen nicht überein.";
return;
}
options = new BackupOptions
{
Secrets = SecretMode.Passphrase,
Passphrase = Passphrase,
IncludeLogs = IncludeLogs,
IncludeChatHistory = IncludeChatHistory
};
}
else
{
options = new BackupOptions
{
Secrets = SecretMode.Exclude,
IncludeLogs = IncludeLogs,
IncludeChatHistory = IncludeChatHistory
};
}
Directory.CreateDirectory(folder);
var fileName = BuildFileName();
var zipPath = Path.Combine(folder, fileName);
IsBusy = true;
StatusText = "Sicherung läuft…";
try
{
var result = await Task.Run(() => _service.CreateAsync(_instanceDir, zipPath, options));
StatusText = $"Fertig: {Path.GetFileName(result.ZipPath)} ({FormatSize(result.SizeBytes)})";
_logger?.LogInformation("Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes);
Passphrase = "";
PassphraseRepeat = "";
ApplyRotation(folder);
RefreshBackupList();
}
catch (Exception ex)
{
StatusText = $"Fehler: {ex.Message}";
_logger?.LogError(ex, "Sicherung fehlgeschlagen");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void ShowSelectedInFolder()
{
if (SelectedBackup is null) return;
SystemShell.RevealFile(SelectedBackup.FullPath);
}
[RelayCommand]
private void DeleteSelected()
{
if (SelectedBackup is null) return;
try
{
File.Delete(SelectedBackup.FullPath);
RefreshBackupList();
StatusText = $"Gelöscht: {SelectedBackup.FileName}";
}
catch (Exception ex)
{
StatusText = $"Konnte nicht gelöscht werden: {ex.Message}";
}
}
[RelayCommand]
private async Task RestoreSelectedAsync()
{
if (SelectedBackup is null || RequestRestoreDialog is null) return;
BackupManifest manifest;
try
{
manifest = await _service.InspectAsync(SelectedBackup.FullPath);
}
catch (Exception ex)
{
StatusText = $"Sicherung unlesbar: {ex.Message}";
return;
}
var suggestedTarget = SuggestRestoreTarget(manifest);
var vm = new RestoreBackupViewModel(manifest, suggestedTarget);
var resultData = await RequestRestoreDialog(vm);
if (resultData is null) return;
IsBusy = true;
StatusText = "Wiederherstellung läuft…";
try
{
var restoreOptions = new RestoreOptions
{
Passphrase = resultData.Passphrase,
Overwrite = resultData.Overwrite
};
var result = await Task.Run(() => _service.RestoreAsync(
SelectedBackup.FullPath, resultData.TargetDirectory, restoreOptions));
StatusText = $"{result.Written.Count} Datei(en) wiederhergestellt nach {resultData.TargetDirectory}";
_logger?.LogInformation("Wiederherstellung: {Written} geschrieben nach {Target}",
result.Written.Count, resultData.TargetDirectory);
}
catch (Exception ex)
{
StatusText = $"Wiederherstellung fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Wiederherstellung fehlgeschlagen");
}
finally
{
IsBusy = false;
}
}
private string BuildFileName()
{
var name = string.IsNullOrWhiteSpace(_instanceName) ? "Instanz" : _instanceName;
var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip";
}
private string SuggestRestoreTarget(BackupManifest manifest)
{
var parent = Directory.GetParent(_instanceDir)?.FullName ?? _instanceDir;
var name = string.IsNullOrWhiteSpace(manifest.InstanceName) ? "Instanz" : manifest.InstanceName;
var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return Path.Combine(parent, $"Instance-{safeName}_wiederhergestellt_{DateTime.Now:yyyyMMdd_HHmm}");
}
private void ApplyRotation(string folder)
{
if (KeepCount <= 0 || !Directory.Exists(folder)) return;
try
{
var prefix = $"backup_{_instanceName}_";
var obsolete = new DirectoryInfo(folder)
.GetFiles("*.zip")
.Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.LastWriteTime)
.Skip(KeepCount)
.ToList();
foreach (var file in obsolete)
{
file.Delete();
_logger?.LogInformation("Alte Sicherung rotiert: {Name}", file.Name);
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
}
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
< 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
_ => $"{bytes / 1024.0 / 1024 / 1024:F2} GB"
};
}
@@ -0,0 +1,158 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record ChatEntryItemViewModel(
string Role,
string Sender,
string Text,
DateTime Timestamp,
bool IsUser
);
/// <summary>
/// Ansichtsmodell für die native Chat-Seite (Avalonia UI statt WebView2).
/// </summary>
public sealed partial class ChatPageViewModel : PageViewModel
{
private readonly AppHost? _host;
public ObservableCollection<AgentConfig> AvailableAgents { get; } = [];
public ObservableCollection<ChatEntryItemViewModel> ChatEntries { get; } = [];
[ObservableProperty]
private AgentConfig? _selectedAgent;
[ObservableProperty]
private string _inputPrompt = "";
[ObservableProperty]
private bool _isSending;
[ObservableProperty]
private string _statusText = "Bereit";
public ChatPageViewModel() : this(null) { }
public ChatPageViewModel(AppHost? host) : base("Chat")
{
_host = host;
if (host is not null)
{
foreach (var agent in host.Instance.Agents)
AvailableAgents.Add(agent);
SelectedAgent = AvailableAgents.FirstOrDefault();
if (host.Engine is not null)
{
host.Engine.OnChatEntryAdded += OnEngineChatEntryAdded;
}
}
else
{
StatusText = "Entwurfsmodus";
}
}
partial void OnSelectedAgentChanged(AgentConfig? value)
{
LoadChatHistory();
}
private void LoadChatHistory()
{
ChatEntries.Clear();
if (_host?.Engine is null || SelectedAgent is null) return;
var history = _host.Engine.GetChatHistory(SelectedAgent.AgentId);
foreach (var entry in history)
{
var isUser = entry.Role.Equals("user", StringComparison.OrdinalIgnoreCase);
ChatEntries.Add(new ChatEntryItemViewModel(
entry.Role,
isUser ? "Du" : SelectedAgent.DisplayName,
entry.Content,
entry.Timestamp,
isUser
));
}
StatusText = $"Chat-Verlauf für '{SelectedAgent.DisplayName}' geladen ({ChatEntries.Count} Nachrichten).";
}
private void OnEngineChatEntryAdded(string agentId, string role, string text, string? toolName)
{
if (SelectedAgent is null || SelectedAgent.AgentId != agentId) return;
Dispatcher.UIThread.Post(() =>
{
var isUser = role.Equals("user", StringComparison.OrdinalIgnoreCase);
var sender = isUser ? "Du" : (string.IsNullOrWhiteSpace(toolName) ? SelectedAgent.DisplayName : $"Tool: {toolName}");
ChatEntries.Add(new ChatEntryItemViewModel(
role, sender, text, DateTime.Now, isUser
));
});
}
[RelayCommand]
private async Task SendMessageAsync()
{
if (_host?.Engine is null || SelectedAgent is null)
{
StatusText = "Engine nicht aktiv oder kein Agent ausgewählt.";
return;
}
var text = InputPrompt.Trim();
if (string.IsNullOrWhiteSpace(text)) return;
InputPrompt = "";
IsSending = true;
StatusText = $"{SelectedAgent.DisplayName} denkt nach…";
try
{
var agent = SelectedAgent;
var instanceId = _host.Instance.InstanceId;
var result = await Task.Run(() =>
_host.Engine.ChatAsync(agent, text, instanceId, CancellationToken.None));
StatusText = $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Fehler: {ex.Message}";
}
finally
{
IsSending = false;
}
}
[RelayCommand]
private void Abort()
{
if (_host?.Engine is null || SelectedAgent is null) return;
_host.Engine.AbortChat(SelectedAgent.AgentId);
StatusText = "Chat abgebrochen.";
IsSending = false;
}
[RelayCommand]
private void ClearHistory()
{
if (_host?.Engine is null || SelectedAgent is null) return;
_host.Engine.ClearChatHistory(SelectedAgent.AgentId);
ChatEntries.Clear();
StatusText = "Verlauf geleert.";
}
}
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using System.Reflection;
using ClawdDotNet.App;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AssemblyInfoItem(string ShortName, int BuildNumber, string BuildDate, string Changes);
/// <summary>
/// Ansichtsmodell für die Info-Seite (Version, Build-Informationen, Instanz-Details).
/// </summary>
public sealed partial class InfoPageViewModel : PageViewModel
{
[ObservableProperty]
private string _appVersion = "—";
[ObservableProperty]
private string _buildSummary = "—";
[ObservableProperty]
private string _instanceName = "—";
[ObservableProperty]
private string _instanceId = "—";
[ObservableProperty]
private string _instancePath = "—";
[ObservableProperty]
private string _logDirectory = "—";
[ObservableProperty]
private string _openRouterStatus = "—";
public ObservableCollection<AssemblyInfoItem> Assemblies { get; } = [];
public InfoPageViewModel() : this(null) { }
public InfoPageViewModel(AppHost? host) : base("Info")
{
AppVersion = AppHost.AppVersion;
BuildSummary = AppHost.BuildSummary;
if (host is not null)
{
InstanceName = host.Instance.InstanceName;
InstanceId = host.Instance.InstanceId;
InstancePath = host.InstancePath;
LogDirectory = host.LogDirectory;
OpenRouterStatus = host.Engine is null
? "Deaktiviert (kein API-Key hinterlegt)"
: "Aktiv (AgentEngine bereit)";
}
else
{
InstanceName = "Entwurfsmodus";
InstanceId = "00000000-0000-0000-0000-000000000000";
InstancePath = @"C:\ClawdDotNet\Instances\Default";
LogDirectory = @"C:\ClawdDotNet\Logs";
OpenRouterStatus = "Entwurf";
}
LoadAssemblyBuildInfos();
}
private void LoadAssemblyBuildInfos()
{
Assemblies.Clear();
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith("ClawdDotNet") == true)
.OrderBy(a => a.GetName().Name))
{
var name = asm.GetName().Name!;
var shortName = name.Replace("ClawdDotNet.", "").Replace("Tools.", "");
var buildDate = asm.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.OfType<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "BuildDate")?.Value ?? "?";
var buildInfoType = asm.GetType(asm.GetName().Name + ".BuildInfo");
var buildNum = buildInfoType?.GetField("Build")?.GetValue(null) as int? ?? 0;
var changes = buildInfoType?.GetField("Changes")?.GetValue(null) as string ?? "—";
Assemblies.Add(new AssemblyInfoItem(shortName, buildNum, buildDate, changes));
}
}
}
@@ -0,0 +1,103 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Auswahl der Instanz beim Start — der erste Rückruf, den <c>AppHost</c> erfragt.
///
/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in
/// einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
/// liegt in der Ansicht, und Rückfragen laufen über <see cref="ConfirmError"/> statt über
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.
/// </summary>
public sealed partial class InstancePickerViewModel : ViewModelBase
{
private readonly InstanceDirectoryManager _directories;
private readonly TaskCompletionSource<string?> _completion = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
private InstanceInfo? _selected;
[ObservableProperty]
private string _newInstanceName = "";
/// <summary>Fehlertext für die Ansicht; leer heißt: alles in Ordnung.</summary>
[ObservableProperty]
private string _errorText = "";
public ObservableCollection<InstanceInfo> Instances { get; } = [];
/// <summary>Der gewählte Pfad, oder <c>null</c> bei Abbruch.</summary>
public Task<string?> Result => _completion.Task;
public string InstancesDirectory => _directories.InstancesDirectory;
public InstancePickerViewModel(InstanceDirectoryManager directories)
{
_directories = directories;
Refresh();
}
[RelayCommand]
private void Refresh()
{
Instances.Clear();
foreach (var instance in _directories.ListInstances())
Instances.Add(instance);
// Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick
// weniger bei jedem Start.
Selected ??= Instances.FirstOrDefault();
}
private bool CanStart => Selected is not null;
[RelayCommand(CanExecute = nameof(CanStart))]
private void Start() => _completion.TrySetResult(Selected!.FolderPath);
/// <summary>Abbruch — der Aufrufer beendet die Anwendung.</summary>
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
[RelayCommand]
private void Create()
{
var name = NewInstanceName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
ErrorText = "Bitte einen Instanznamen eingeben.";
return;
}
try
{
_directories.CreateInstance(name);
NewInstanceName = "";
ErrorText = "";
Refresh();
// Die frisch angelegte Instanz vorwählen — wer sie erstellt, will sie starten.
Selected = Instances.FirstOrDefault(i =>
string.Equals(i.InstanceName, name, StringComparison.Ordinal));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or ArgumentException)
{
ErrorText = $"Instanz konnte nicht angelegt werden: {ex.Message}";
}
}
/// <summary>
/// Wird gesetzt, wenn ein Fehler von außen kommt (etwa eine unlesbare
/// Instanzkonfiguration), damit das Fenster offen bleiben und eine andere Wahl
/// zulassen kann.
/// </summary>
public void ConfirmError(string message) => ErrorText = message;
}
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Eingabe eines Lizenzschlüssels. Siehe <c>AvaloniaLicensePrompt</c>.</summary>
public sealed partial class LicenseViewModel : ViewModelBase
{
private readonly TaskCompletionSource<string?> _completion = new();
[ObservableProperty]
private string _key;
public string HardwareId { get; }
/// <summary>Warum der bisherige Schlüssel nicht taugt; leer beim ersten Fragen.</summary>
public string Problem { get; }
/// <summary>Der eingegebene Schlüssel, oder <c>null</c> bei Abbruch.</summary>
public Task<string?> Result => _completion.Task;
public LicenseViewModel(string hardwareId, string? problem, string? currentKey)
{
HardwareId = hardwareId;
Problem = problem ?? "";
_key = currentKey ?? "";
}
[RelayCommand]
private void Confirm()
{
var value = Key.Trim();
// Leere Eingabe ist kein Abbruch — sonst schließt sich das Fenster, wenn jemand
// versehentlich die Eingabetaste drückt.
if (value.Length == 0) return;
_completion.TrySetResult(value);
}
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
/// <summary>Schließt der Benutzer das Fenster, gilt das als Abbruch.</summary>
public void CancelIfPending() => _completion.TrySetResult(null);
}
@@ -0,0 +1,112 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Live-Ansicht der Logdateien.
///
/// Der Takt läuft über <see cref="DispatcherTimer"/> — das Avalonia-Gegenstück zum
/// <c>System.Windows.Forms.Timer</c>, also auf dem Oberflächenfaden. Für eine Ansicht
/// ist das genau richtig: Das Lesen selbst dauert Millisekunden, und so entfällt jedes
/// Marshalling. Alles, was ohne Fenster laufen muss, liegt dagegen in
/// <see cref="LogTail"/> und taktet dort über <c>PeriodicTimer</c>.
/// </summary>
public sealed partial class LogPageViewModel : PageViewModel
{
/// <summary>
/// Obergrenze der angezeigten Zeilen.
///
/// Ohne Deckel wächst die Liste unbegrenzt — ein Agent, der eine Nacht durchläuft,
/// hinterlässt sonst Hunderttausende Einträge im Speicher, und das Scrollen wird zäh.
/// </summary>
private const int MaxLines = 2000;
private readonly LogTail? _tail;
private readonly DispatcherTimer? _timer;
private readonly string _logDirectory;
public ObservableCollection<LogLine> Lines { get; } = [];
public ObservableCollection<string> Modules { get; } = ["Alle"];
public IReadOnlyList<string> Levels { get; } = ["Alle", "Info", "Warn", "Error"];
[ObservableProperty]
private string _selectedModule = "Alle";
[ObservableProperty]
private string _selectedLevel = "Alle";
/// <summary>
/// Hängt die Ansicht am unteren Ende? Sobald jemand nach oben scrollt, soll nicht
/// weitergesprungen werden — sonst kann man nichts lesen, während etwas läuft.
/// </summary>
[ObservableProperty]
private bool _followTail = true;
public LogPageViewModel(AppHost? host) : base("Logs")
{
_logDirectory = host?.LogDirectory ?? "";
if (host is null) return; // Entwurfsmodus
_tail = new LogTail(_logDirectory);
RefreshModules();
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_timer.Tick += (_, _) => Poll();
_timer.Start();
}
private void Poll()
{
if (_tail is null) return;
var module = SelectedModule == "Alle" ? null : SelectedModule;
var level = SelectedLevel switch
{
"Info" => LogLevelFilter.Info,
"Warn" => LogLevelFilter.Warn,
"Error" => LogLevelFilter.Error,
_ => LogLevelFilter.All
};
foreach (var line in _tail.ReadNew(module, level))
{
Lines.Add(line);
// Von vorn kürzen statt am Ende zu deckeln: Das Neueste soll bleiben.
while (Lines.Count > MaxLines)
Lines.RemoveAt(0);
}
}
[RelayCommand]
private void Clear() => Lines.Clear();
[RelayCommand]
private void RefreshModules()
{
if (_tail is null) return;
var current = SelectedModule;
Modules.Clear();
Modules.Add("Alle");
foreach (var module in _tail.AvailableModules())
Modules.Add(module);
// Auswahl halten, wenn es das Modul noch gibt.
SelectedModule = Modules.Contains(current) ? current : "Alle";
}
[RelayCommand]
private void OpenFolder() => SystemShell.OpenFolder(_logDirectory);
}
@@ -0,0 +1,102 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist.
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppHost? _host;
[ObservableProperty]
private PageViewModel? _selectedPage;
public ObservableCollection<PageViewModel> Pages { get; }
/// <summary>Text der Statusleiste am unteren Rand.</summary>
[ObservableProperty]
private string _statusText = "Bereit";
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
/// <summary>Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.</summary>
public MainWindowViewModel() : this(null) { }
public MainWindowViewModel(AppHost? host)
{
_host = host;
Pages =
[
new ChatPageViewModel(host),
new LogPageViewModel(host),
new AgentsPageViewModel(host),
new SettingsPageViewModel(host),
new TasksPageViewModel(host),
new BackupPageViewModel(host),
new InfoPageViewModel(host)
];
SelectedPage = Pages[0];
StatusText = host is null
? "Entwurfsmodus"
: $"Instanz: {host.Instance.InstanceName} · "
+ (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit");
}
[RelayCommand]
private void OpenInstanceFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.InstancePath);
}
[RelayCommand]
private void OpenLogFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.LogDirectory);
}
[RelayCommand]
private void OpenInstanceManager()
{
RequestInstanceManagerDialog?.Invoke();
}
[RelayCommand]
private void SelectPageByTitle(string title)
{
var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
if (target is not null)
SelectedPage = target;
}
[RelayCommand]
private void Exit()
{
RequestExit?.Invoke();
}
}
/// <summary>Ein Bereich des Hauptfensters.</summary>
public abstract partial class PageViewModel(string title) : ViewModelBase
{
public string Title { get; } = title;
}
/// <summary>
/// Platzhalter, solange der Bereich noch nicht portiert ist.
/// </summary>
public sealed partial class PlaceholderPageViewModel(string title, string note)
: PageViewModel(title)
{
public string Note { get; } = note;
}
@@ -0,0 +1,77 @@
using ClawdDotNet.Core.Backup;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record RestoreResultData(string TargetDirectory, string? Passphrase, bool Overwrite);
public sealed partial class RestoreBackupViewModel : ViewModelBase
{
private readonly BackupManifest _manifest;
public string InstanceName => _manifest.InstanceName;
public DateTime CreatedAt => _manifest.CreatedAt;
public int FileCount => _manifest.Files.Count;
public bool NeedsPassphrase => _manifest.HasSecrets;
public string SecretsDescription => _manifest.HasSecrets
? $"{_manifest.SecretCount}, mit Passphrase geschützt"
: _manifest.SecretCount > 0
? $"{_manifest.SecretCount} entfernt"
: "keine";
[ObservableProperty]
private string _targetDirectory = "";
[ObservableProperty]
private string _passphrase = "";
[ObservableProperty]
private bool _overwrite;
[ObservableProperty]
private string _statusMessage = "";
public RestoreResultData? Result { get; private set; }
public event Action? CloseRequested;
public RestoreBackupViewModel() : this(new BackupManifest(), @"C:\Instance_Restored") { }
public RestoreBackupViewModel(BackupManifest manifest, string suggestedTarget)
{
_manifest = manifest;
TargetDirectory = suggestedTarget;
}
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(TargetDirectory))
{
StatusMessage = "Bitte einen Zielordner angeben.";
return;
}
if (NeedsPassphrase && string.IsNullOrEmpty(Passphrase))
{
StatusMessage = "Bitte Passphrase für geschützte Zugangsdaten eingeben.";
return;
}
Result = new RestoreResultData(
TargetDirectory.Trim(),
NeedsPassphrase ? Passphrase : null,
Overwrite);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,183 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Ansichtsmodell für die Einstellungen-Seite (Anwendungs- und Instanz-Einstellungen).
/// </summary>
public sealed partial class SettingsPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
// ─── Allgemein & Pfade ───
[ObservableProperty]
private string _logDirectory = "";
[ObservableProperty]
private string _instancesDirectory = "";
[ObservableProperty]
private string _minimumLogLevel = "Info";
[ObservableProperty]
private int _maxLogLinesInUi = 2000;
[ObservableProperty]
private int _logRefreshIntervalMs = 500;
// ─── API & OpenRouter ───
[ObservableProperty]
private string _openRouterBaseUrl = "https://openrouter.ai/api/v1/";
[ObservableProperty]
private int _statusCheckIntervalSeconds = 60;
// ─── Backup ───
[ObservableProperty]
private string _backupDirectory = "";
[ObservableProperty]
private bool _autoBackupEnabled;
[ObservableProperty]
private string _autoBackupTime = "03:00";
[ObservableProperty]
private int _backupKeepCount = 14;
// ─── Deploymentcenter ───
[ObservableProperty]
private string _deploymentcenterUrl = "https://dc.mhdf.de";
[ObservableProperty]
private string _deploymentcenterToken = "";
[ObservableProperty]
private string _deploymentcenterEnvironment = "production";
[ObservableProperty]
private bool _errorReportingEnabled = true;
[ObservableProperty]
private bool _updateCheckEnabled = true;
[ObservableProperty]
private string _updateChannel = "prod";
// ─── Lizenz ───
[ObservableProperty]
private string _licenseKey = "";
// ─── Instanz-Einstellungen ───
[ObservableProperty]
private string _instanceName = "";
[ObservableProperty]
private string _openRouterApiKey = "";
[ObservableProperty]
private decimal _instanceDailyCostUsd;
[ObservableProperty]
private long _instanceDailyTokens;
[ObservableProperty]
private string _statusText = "Bereit";
public List<string> LogLevels { get; } = ["Debug", "Info", "Warn", "Error"];
public List<string> UpdateChannels { get; } = ["prod", "beta", "dev"];
public List<string> Environments { get; } = ["production", "development", "staging"];
public SettingsPageViewModel() : this(null) { }
public SettingsPageViewModel(AppHost? host) : base("Einstellungen")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Settings");
LoadFromHost();
}
else
{
StatusText = "Entwurfsmodus";
}
}
[RelayCommand]
private void LoadFromHost()
{
if (_host is null) return;
var s = _host.Settings.AppSettings;
LogDirectory = s.LogDirectory;
InstancesDirectory = s.InstancesDirectory;
MinimumLogLevel = s.MinimumLogLevel;
MaxLogLinesInUi = s.MaxLogLinesInUi;
LogRefreshIntervalMs = s.LogRefreshIntervalMs;
OpenRouterBaseUrl = s.OpenRouterBaseUrl;
StatusCheckIntervalSeconds = s.StatusCheckIntervalSeconds;
BackupDirectory = s.BackupDirectory;
AutoBackupEnabled = s.AutoBackupEnabled;
AutoBackupTime = s.AutoBackupTime;
BackupKeepCount = s.BackupKeepCount;
DeploymentcenterUrl = s.DeploymentcenterUrl;
DeploymentcenterToken = s.DeploymentcenterToken;
DeploymentcenterEnvironment = s.DeploymentcenterEnvironment;
ErrorReportingEnabled = s.ErrorReportingEnabled;
UpdateCheckEnabled = s.UpdateCheckEnabled;
UpdateChannel = s.UpdateChannel;
LicenseKey = s.LicenseKey;
var inst = _host.Instance;
InstanceName = inst.InstanceName;
OpenRouterApiKey = inst.OpenRouterApiKey;
InstanceDailyCostUsd = inst.Budget.DailyCostUsd;
InstanceDailyTokens = inst.Budget.DailyTokens;
}
[RelayCommand]
private void SaveSettings()
{
if (_host is null) return;
var s = _host.Settings.AppSettings;
s.LogDirectory = LogDirectory.Trim();
s.InstancesDirectory = InstancesDirectory.Trim();
s.MinimumLogLevel = MinimumLogLevel;
s.MaxLogLinesInUi = MaxLogLinesInUi;
s.LogRefreshIntervalMs = LogRefreshIntervalMs;
s.OpenRouterBaseUrl = OpenRouterBaseUrl.Trim();
s.StatusCheckIntervalSeconds = StatusCheckIntervalSeconds;
s.BackupDirectory = BackupDirectory.Trim();
s.AutoBackupEnabled = AutoBackupEnabled;
s.AutoBackupTime = AutoBackupTime.Trim();
s.BackupKeepCount = BackupKeepCount;
s.DeploymentcenterUrl = DeploymentcenterUrl.Trim();
s.DeploymentcenterToken = DeploymentcenterToken.Trim();
s.DeploymentcenterEnvironment = DeploymentcenterEnvironment.Trim();
s.ErrorReportingEnabled = ErrorReportingEnabled;
s.UpdateCheckEnabled = UpdateCheckEnabled;
s.UpdateChannel = UpdateChannel;
s.LicenseKey = LicenseKey.Trim();
_host.Settings.Save();
var inst = _host.Instance;
inst.InstanceName = InstanceName.Trim();
inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
inst.Budget.DailyTokens = InstanceDailyTokens;
_host.Directories.SaveInstanceConfig(_host.InstancePath, inst);
_logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert.");
StatusText = "Einstellungen erfolgreich gespeichert.";
}
}
@@ -0,0 +1,446 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClawdDotNet.Core.Scheduling;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record JobDisplayItemViewModel(
string JobType,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string CronExpression,
string TaskMessage,
bool RunOnStart,
string NextRun,
string LastRun,
string LastStatus,
string Status
);
public sealed record ServiceDisplayItemViewModel(
string ServiceId,
string Name,
string Type,
int Port,
string Status,
string Description
);
/// <summary>
/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf).
/// </summary>
public sealed partial class TasksPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly JobHistoryService? _jobHistoryService;
private readonly ILogger? _logger;
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
public ObservableCollection<JobDisplayItemViewModel> JobEntries { get; } = [];
public ObservableCollection<ServiceDisplayItemViewModel> ServiceEntries { get; } = [];
public ObservableCollection<JobHistoryEntry> JobHistoryEntries { get; } = [];
[ObservableProperty]
private JobDisplayItemViewModel? _selectedJob;
[ObservableProperty]
private ServiceDisplayItemViewModel? _selectedService;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunningJob;
public event Func<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? RequestAddServiceDialog;
public TasksPageViewModel() : this(null) { }
public TasksPageViewModel(AppHost? host) : base("Aufgaben")
{
_host = host;
if (host is not null)
{
_jobHistoryService = new JobHistoryService(host.InstancePath);
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
EnsureBuiltInServices();
RefreshJobList();
RefreshServiceList();
RefreshJobHistory();
}
else
{
StatusText = "Entwurfsmodus";
}
}
private void SaveInstanceConfig()
{
if (_host is null) return;
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
}
private void EnsureBuiltInServices()
{
if (_host is null) return;
var defaults = BuiltInServices.CreateDefaults();
var changed = false;
foreach (var def in defaults)
{
if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId))
continue;
_host.Instance.Services.Insert(0, def);
changed = true;
}
if (changed)
SaveInstanceConfig();
}
[RelayCommand]
private void RefreshJobList()
{
JobEntries.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
// Agent Wakeup Jobs
if (agent.Scheduler is not null)
{
var nextRun = CalculateNextRun(agent.Scheduler.Cron);
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
JobEntries.Add(new JobDisplayItemViewModel(
"Agent Wakeup",
$"agent_{agent.AgentId}",
agent.AgentId,
agent.DisplayName,
"—",
agent.Scheduler.Cron,
agent.Scheduler.TaskMessage,
agent.Scheduler.RunOnStart,
nextRun,
agentLastRun,
"—",
_host.Scanner is not null ? "Aktiv" : "Inaktiv"
));
}
// Tool Jobs
foreach (var toolJob in agent.ToolJobs)
{
var nextRun = CalculateNextRun(toolJob.Cron);
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
JobEntries.Add(new JobDisplayItemViewModel(
"Tool Job",
toolJob.JobId,
agent.AgentId,
agent.DisplayName,
toolJob.ToolName,
toolJob.Cron,
toolJob.JobTypeId,
toolJob.RunOnStart,
nextRun,
toolLastRun,
"—",
toolJob.Enabled ? "Aktiv" : "Deaktiviert"
));
}
}
}
[RelayCommand]
private void RefreshServiceList()
{
ServiceEntries.Clear();
if (_host is null) return;
foreach (var svc in _host.Instance.Services)
{
ServiceEntries.Add(new ServiceDisplayItemViewModel(
svc.ServiceId,
svc.Name,
svc.Type,
svc.Port,
svc.Enabled ? "Bereit" : "Deaktiviert",
svc.Description
));
}
}
[RelayCommand]
private void RefreshJobHistory()
{
JobHistoryEntries.Clear();
if (_jobHistoryService is null) return;
foreach (var entry in _jobHistoryService.GetAll().Take(200))
JobHistoryEntries.Add(entry);
}
[RelayCommand]
private async Task AddJobAsync()
{
if (_host is null || RequestAddJobDialog is null) return;
var vm = new AddJobViewModel(_host.Instance.Agents);
var result = await RequestAddJobDialog(vm);
if (result is null) return;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
if (agent is null) return;
if (result.IsToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = result.ToolName,
JobTypeId = result.JobTypeId,
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
Enabled = true
});
_logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}",
agent.DisplayName, result.ToolName, result.CronExpression);
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
TaskMessage = result.TaskMessage
};
_logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}",
agent.DisplayName, result.CronExpression);
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private async Task EditJobAsync()
{
if (_host is null || SelectedJob is null || RequestAddJobDialog is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
var isToolJob = entry.JobType == "Tool Job";
var vm = new AddJobViewModel(_host.Instance.Agents)
{
SelectedAgent = agent,
IsToolJob = isToolJob,
SelectedToolName = isToolJob ? entry.ToolName : "FileRW",
JobTypeId = isToolJob ? entry.TaskMessage : "PollJob",
CronExpression = entry.CronExpression,
TaskMessage = isToolJob ? "" : entry.TaskMessage,
RunOnStart = entry.RunOnStart
};
var result = await RequestAddJobDialog(vm);
if (result is null) return;
if (isToolJob)
{
var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (toolJob is not null)
{
toolJob.Cron = result.CronExpression;
toolJob.RunOnStart = result.RunOnStart;
toolJob.JobTypeId = result.JobTypeId;
toolJob.ToolName = result.ToolName;
}
}
else
{
if (agent.Scheduler is not null)
{
agent.Scheduler.Cron = result.CronExpression;
agent.Scheduler.TaskMessage = result.TaskMessage;
agent.Scheduler.RunOnStart = result.RunOnStart;
}
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private void RemoveJob()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
if (entry.JobType == "Tool Job")
{
agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId);
}
else
{
agent.Scheduler = null;
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private async Task RunJobNowAsync()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
IsRunningJob = true;
StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
try
{
if (entry.JobType == "Tool Job")
{
if (_host.Scanner is null)
{
StatusText = "Scanner ist nicht aktiv.";
return;
}
var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (jobConfig is null) return;
var ran = await _host.Scanner.RunTaskNowAsync(
$"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
var info = ran ? "Ausgeführt" : "Nicht bereit";
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = jobConfig.JobTypeId,
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = $"Tool Job: {jobConfig.ToolName}",
Info = info,
Status = "Manual"
});
StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info}).";
}
else
{
if (_host.Engine is null)
{
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None);
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = "Agent Wakeup",
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = taskMessage,
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
Status = "Manual"
});
StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens).";
}
RefreshJobList();
RefreshJobHistory();
}
catch (Exception ex)
{
StatusText = $"Fehler beim Ausführen: {ex.Message}";
_logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
}
finally
{
IsRunningJob = false;
}
}
[RelayCommand]
private async Task AddServiceAsync()
{
if (_host is null || RequestAddServiceDialog is null) return;
var vm = new AddServiceViewModel();
var result = await RequestAddServiceDialog(vm);
if (result is null) return;
var svcConfig = new ServiceConfig
{
Name = result.ServiceName,
Type = result.ServiceType,
Port = result.ServicePort,
Enabled = true,
Description = result.ServiceDescription
};
_host.Instance.Services.Add(svcConfig);
SaveInstanceConfig();
RefreshServiceList();
}
[RelayCommand]
private void RemoveService()
{
if (_host is null || SelectedService is null) return;
_host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
SaveInstanceConfig();
RefreshServiceList();
}
private static string CalculateNextRun(string? cronExpr)
{
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
try
{
var cron = CronExpression.Parse(cronExpr);
var next = cron.GetNextOccurrence(DateTime.UtcNow);
return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
}
catch
{
return "Ungültig";
}
}
}
@@ -0,0 +1,221 @@
using ClawdDotNet.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolSettingsViewModel : ViewModelBase
{
public string ToolName { get; }
public object? SettingsObject { get; }
// FTP
[ObservableProperty] private string _ftpHost = "";
[ObservableProperty] private int _ftpPort = 21;
[ObservableProperty] private string _ftpUsername = "";
[ObservableProperty] private string _ftpPassword = "";
[ObservableProperty] private string _ftpRootPath = "./data/";
// Mail
[ObservableProperty] private string _mailUsername = "";
[ObservableProperty] private string _mailPassword = "";
[ObservableProperty] private string _mailImapHost = "";
[ObservableProperty] private int _mailImapPort = 993;
[ObservableProperty] private string _mailSmtpHost = "";
[ObservableProperty] private int _mailSmtpPort = 587;
[ObservableProperty] private string _mailAllowedRecipients = "";
// Database
[ObservableProperty] private DatabaseType _dbType = DatabaseType.MySql;
[ObservableProperty] private string _dbConnectionString = "";
[ObservableProperty] private DatabaseAccessLevel _dbAccessLevel = DatabaseAccessLevel.ReadOnly;
[ObservableProperty] private string _dbAllowedTables = "";
// Telegram
[ObservableProperty] private string _telegramBotToken = "";
[ObservableProperty] private string _telegramDefaultChatId = "";
[ObservableProperty] private string _telegramAllowedChatIds = "";
// DirectAPI
[ObservableProperty] private string _directApiDefaultProvider = "twelvedata";
[ObservableProperty] private int _directApiCacheTtlSeconds = 60;
[ObservableProperty] private string _directApiTwelveDataKey = "";
[ObservableProperty] private string _directApiAlphaVantageKey = "";
// WebFetch
[ObservableProperty] private string _webFetchAllowedDomains = "";
[ObservableProperty] private int _webFetchMaxResponseKb = 512;
[ObservableProperty] private string _webFetchUserAgent = "ClawdDotNet-Agent/1.0";
// SocialMediaManager
[ObservableProperty] private string _smmXApiKey = "";
[ObservableProperty] private string _smmXWatchAccounts = "";
[ObservableProperty] private string _smmRedditWatchSubreddits = "";
[ObservableProperty] private int _smmRedditPostLimit = 15;
[ObservableProperty] private string _smmOpenRouterApiKey = "";
[ObservableProperty] private string _smmSttModel = "openai/whisper-1";
[ObservableProperty] private string _smmYoutubeChannels = "";
// FileRW
[ObservableProperty] private string _fileRwPersonalAllowedExtensions = ".txt,.json,.md,.html,.js,.css";
[ObservableProperty] private FileRWAccessLevel _fileRwSharedAccessLevel = FileRWAccessLevel.Denied;
[ObservableProperty] private string _fileRwSharedAllowedExtensions = ".txt,.json,.md";
[ObservableProperty] private string _fileRwProtectedPaths = "stocks/";
public List<DatabaseType> DbTypes { get; } = [DatabaseType.MySql, DatabaseType.Postgres, DatabaseType.MsSql, DatabaseType.MongoDb];
public List<DatabaseAccessLevel> DbAccessLevels { get; } = [DatabaseAccessLevel.ReadOnly, DatabaseAccessLevel.ReadWrite, DatabaseAccessLevel.Admin];
public List<FileRWAccessLevel> FileRwAccessLevels { get; } = [FileRWAccessLevel.Denied, FileRWAccessLevel.Read, FileRWAccessLevel.ReadWrite, FileRWAccessLevel.Admin];
public Dictionary<string, object?>? Result { get; private set; }
public event Action? CloseRequested;
public ToolSettingsViewModel() : this("FTP", new()) { }
public ToolSettingsViewModel(string toolName, Dictionary<string, object?> config)
{
ToolName = toolName;
SettingsObject = ToolSettingsFactory.CreateViewModel(toolName, config);
switch (SettingsObject)
{
case FTPToolSettings ftp:
FtpHost = ftp.Host;
FtpPort = ftp.Port;
FtpUsername = ftp.Username;
FtpPassword = ftp.Password;
FtpRootPath = ftp.RootPath;
break;
case MailToolSettings mail:
MailUsername = mail.Username;
MailPassword = mail.Password;
MailImapHost = mail.ImapHost;
MailImapPort = mail.ImapPort;
MailSmtpHost = mail.SmtpHost;
MailSmtpPort = mail.SmtpPort;
MailAllowedRecipients = mail.AllowedRecipients;
break;
case DatabaseToolSettings db:
DbType = db.Type;
DbConnectionString = db.ConnectionString;
DbAccessLevel = db.AccessLevel;
DbAllowedTables = db.AllowedTables;
break;
case TelegramToolSettings tg:
TelegramBotToken = tg.BotToken;
TelegramDefaultChatId = tg.DefaultChatId;
TelegramAllowedChatIds = tg.AllowedChatIds;
break;
case DirectAPIToolSettings dapi:
DirectApiDefaultProvider = dapi.DefaultProvider;
DirectApiCacheTtlSeconds = dapi.CacheTtlSeconds;
DirectApiTwelveDataKey = dapi.TwelveDataKey;
DirectApiAlphaVantageKey = dapi.AlphaVantageKey;
break;
case WebFetchToolSettings wf:
WebFetchAllowedDomains = wf.AllowedDomains;
WebFetchMaxResponseKb = wf.MaxResponseKb;
WebFetchUserAgent = wf.UserAgent;
break;
case SocialMediaManagerToolSettings smm:
SmmXApiKey = smm.XApiKey;
SmmXWatchAccounts = smm.XWatchAccounts;
SmmRedditWatchSubreddits = smm.RedditWatchSubreddits;
SmmRedditPostLimit = smm.RedditPostLimit;
SmmOpenRouterApiKey = smm.OpenRouterApiKey;
SmmSttModel = smm.STTModel;
SmmYoutubeChannels = smm.YoutubeChannels;
break;
case FileRWToolSettings frw:
FileRwPersonalAllowedExtensions = frw.PersonalAllowedExtensions;
FileRwSharedAccessLevel = frw.SharedAccessLevel;
FileRwSharedAllowedExtensions = frw.SharedAllowedExtensions;
FileRwProtectedPaths = frw.ProtectedPaths;
break;
}
}
[RelayCommand]
private void Confirm()
{
switch (SettingsObject)
{
case FTPToolSettings ftp:
ftp.Host = FtpHost;
ftp.Port = FtpPort;
ftp.Username = FtpUsername;
ftp.Password = FtpPassword;
ftp.RootPath = FtpRootPath;
break;
case MailToolSettings mail:
mail.Username = MailUsername;
mail.Password = MailPassword;
mail.ImapHost = MailImapHost;
mail.ImapPort = MailImapPort;
mail.SmtpHost = MailSmtpHost;
mail.SmtpPort = MailSmtpPort;
mail.AllowedRecipients = MailAllowedRecipients;
break;
case DatabaseToolSettings db:
db.Type = DbType;
db.ConnectionString = DbConnectionString;
db.AccessLevel = DbAccessLevel;
db.AllowedTables = DbAllowedTables;
break;
case TelegramToolSettings tg:
tg.BotToken = TelegramBotToken;
tg.DefaultChatId = TelegramDefaultChatId;
tg.AllowedChatIds = TelegramAllowedChatIds;
break;
case DirectAPIToolSettings dapi:
dapi.DefaultProvider = DirectApiDefaultProvider;
dapi.CacheTtlSeconds = DirectApiCacheTtlSeconds;
dapi.TwelveDataKey = DirectApiTwelveDataKey;
dapi.AlphaVantageKey = DirectApiAlphaVantageKey;
break;
case WebFetchToolSettings wf:
wf.AllowedDomains = WebFetchAllowedDomains;
wf.MaxResponseKb = WebFetchMaxResponseKb;
wf.UserAgent = WebFetchUserAgent;
break;
case SocialMediaManagerToolSettings smm:
smm.XApiKey = SmmXApiKey;
smm.XWatchAccounts = SmmXWatchAccounts;
smm.RedditWatchSubreddits = SmmRedditWatchSubreddits;
smm.RedditPostLimit = SmmRedditPostLimit;
smm.OpenRouterApiKey = SmmOpenRouterApiKey;
smm.STTModel = SmmSttModel;
smm.YoutubeChannels = SmmYoutubeChannels;
break;
case FileRWToolSettings frw:
frw.PersonalAllowedExtensions = FileRwPersonalAllowedExtensions;
frw.SharedAccessLevel = FileRwSharedAccessLevel;
frw.SharedAllowedExtensions = FileRwSharedAllowedExtensions;
frw.ProtectedPaths = FileRwProtectedPaths;
break;
}
Result = ToolSettingsFactory.ToConfig(ToolName, SettingsObject) ?? new();
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,15 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Grundlage aller Ansichtsmodelle.
///
/// <see cref="ObservableObject"/> aus dem CommunityToolkit bringt
/// <c>INotifyPropertyChanged</c> samt Quelltextgenerator mit — aus
/// <c>[ObservableProperty] private string _name;</c> wird die vollständige Eigenschaft
/// mit Benachrichtigung. Das spart gegenüber der WinForms-Fassung, in der die
/// Oberfläche bei jeder Änderung von Hand nachgezogen wurde
/// (38 Stellen mit <c>Invoke</c>/<c>BeginInvoke</c>).
/// </summary>
public abstract class ViewModelBase : ObservableObject;
@@ -0,0 +1,51 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="380"
Width="480" Height="400"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Neuen Agenten anlegen"
x:Class="ClawdDotNet.Desktop.Views.AddAgentWindow"
x:DataType="vm:AddAgentViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Anlegen" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Agent-ID (eindeutiger Schlüssel):" FontWeight="Bold" />
<TextBox Text="{Binding AgentId}" PlaceholderText="z.B. dev-assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anzeigename:" FontWeight="Bold" />
<TextBox Text="{Binding DisplayName}" PlaceholderText="z.B. Dev Assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Standard-Modell:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableModels}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung / Rolle:" FontWeight="Bold" />
<TextBox Text="{Binding Description}" PlaceholderText="Kurze Beschreibung der Rolle..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddAgentWindow : Window
{
public AddAgentWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddAgentViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,80 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450"
Width="520" Height="460"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Job hinzufügen / bearbeiten"
x:Class="ClawdDotNet.Desktop.Views.AddJobWindow"
x:DataType="vm:AddJobViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Speichern" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<ScrollViewer>
<StackPanel Spacing="12">
<!-- Agent Selection -->
<StackPanel Spacing="4">
<TextBlock Text="Agent:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Job Type RadioButtons -->
<StackPanel Spacing="4" Margin="0,4,0,0">
<TextBlock Text="Job-Typ:" FontWeight="Bold" />
<RadioButton Content="Agent Wakeup (Periodische Ausführung)" IsChecked="{Binding IsAgentWakeup}" />
<RadioButton Content="Tool Job (Werkzeug-spezifischer Job)" IsChecked="{Binding IsToolJob}" />
</StackPanel>
<!-- Tool Job Details -->
<StackPanel Spacing="8" IsVisible="{Binding IsToolJob}" Margin="12,4,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Tool:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableTools}" SelectedItem="{Binding SelectedToolName}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Job-Typ-ID / Name:" FontWeight="Bold" />
<TextBox Text="{Binding JobTypeId}" PlaceholderText="z.B. PollMail" />
</StackPanel>
</StackPanel>
<!-- Agent Wakeup Details -->
<StackPanel Spacing="4" IsVisible="{Binding IsAgentWakeup}">
<TextBlock Text="Aufgabenbeschreibung / Nachricht:" FontWeight="Bold" />
<TextBox Text="{Binding TaskMessage}" PlaceholderText="Nachricht an den Agenten..." AcceptsReturn="True" Height="60" />
</StackPanel>
<!-- Cron & Options -->
<StackPanel Spacing="4">
<TextBlock Text="Cron-Ausdruck:" FontWeight="Bold" />
<TextBox Text="{Binding CronExpression}" PlaceholderText="0 * * * *" />
<TextBlock Text="Syntax: Min Std Tag Mon Wochentag (z.B. '*/15 * * * *' alle 15 Minuten)" Classes="caption" />
</StackPanel>
<CheckBox Content="Beim Anwendungsstart einmalig sofort ausführen" IsChecked="{Binding RunOnStart}" Margin="0,4,0,0" />
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddJobWindow : Window
{
public AddJobWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddJobViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="350"
Width="480" Height="360"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Dienst (Service) hinzufügen"
x:Class="ClawdDotNet.Desktop.Views.AddServiceWindow"
x:DataType="vm:AddServiceViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Hinzufügen" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Name des Dienstes:" FontWeight="Bold" />
<TextBox Text="{Binding ServiceName}" PlaceholderText="z.B. WebHookService" />
</StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<StackPanel Grid.Column="0" Spacing="4" Margin="0,0,4,0">
<TextBlock Text="Typ:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableServiceTypes}" SelectedItem="{Binding ServiceType}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="4" Margin="4,0,0,0">
<TextBlock Text="Port:" FontWeight="Bold" />
<NumericUpDown Value="{Binding ServicePort}" Minimum="1" Maximum="65535" FormatString="0" />
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung:" FontWeight="Bold" />
<TextBox Text="{Binding ServiceDescription}" PlaceholderText="Optionale Beschreibung..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddServiceWindow : Window
{
public AddServiceWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddServiceViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,155 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.AgentsPageView"
x:DataType="vm:AgentsPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<!-- Master-Detail Split -->
<Grid ColumnDefinitions="260,*">
<!-- Linke Spalte: Agenten-Liste -->
<DockPanel Grid.Column="0" Margin="0,0,12,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Neu" Command="{Binding AddAgentCommand}" Classes="accent" />
<Button Content="Entfernen" Command="{Binding RemoveAgentCommand}" IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Speichern" Command="{Binding SaveAgentsCommand}" />
</StackPanel>
<!-- Liste -->
<ListBox ItemsSource="{Binding Agents}" SelectedItem="{Binding SelectedAgent}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<StackPanel Margin="4">
<TextBlock Text="{Binding DisplayName}" FontWeight="Bold" />
<TextBlock Text="{Binding Model}" Classes="caption" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<!-- Rechte Spalte: Agenten-Details -->
<ContentControl Grid.Column="1" Content="{Binding SelectedAgent}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<TabControl>
<!-- TAB 1: Allgemein & Modell -->
<TabItem Header="Allgemein &amp; Modell">
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Agent ID:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding AgentId}" IsReadOnly="True" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anzeigename:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DisplayName}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}" SelectedItem="{Binding Model}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Rolle / Beschreibung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Description}" AcceptsReturn="True" Height="60" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Prompt Caching:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="4" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}" SelectedItem="{Binding PromptCaching}" Width="150" HorizontalAlignment="Left" Margin="0,8,0,0" />
</Grid>
<!-- Limits Card -->
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="Schutzgrenzen (LoopGuard &amp; Budget)" Classes="heading" />
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Max. Schritte pro Run:" />
<NumericUpDown Value="{Binding MaxSteps}" Minimum="1" Maximum="100" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="8,0,0,0">
<TextBlock Text="Timeout (Sekunden):" />
<NumericUpDown Value="{Binding TimeoutSeconds}" Minimum="10" Maximum="3600" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,8,0,0">
<TextBlock Text="Max. Kontext-Tokens:" />
<NumericUpDown Value="{Binding MaxContextTokens}" Minimum="1000" Maximum="2000000" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="8,8,0,0">
<TextBlock Text="Max. Tool-Ergebnis (Zeichen):" />
<NumericUpDown Value="{Binding MaxToolResultChars}" Minimum="1000" Maximum="500000" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 2: System Prompt & Seele -->
<TabItem Header="System-Prompt &amp; Soul">
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="System-Prompt (Zusätzliche Anweisungen):" FontWeight="Bold" />
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="120" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Identität (Identity):" FontWeight="Bold" />
<TextBox Text="{Binding Identity}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Seele &amp; Werte (Soul):" FontWeight="Bold" />
<TextBox Text="{Binding Soul}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 3: Werkzeuge (Tools) -->
<TabItem Header="Werkzeuge (Tools)">
<ScrollViewer Padding="8">
<StackPanel Spacing="8">
<TextBlock Text="Zugelassene Werkzeuge für diesen Agenten:" Classes="heading" />
<ItemsControl ItemsSource="{Binding AvailableTools}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ToolItemViewModel">
<Grid ColumnDefinitions="200,Auto" Margin="0,4">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsEnabled}" Grid.Column="0" VerticalAlignment="Center" />
<Button Content="⚙ Konfigurieren" Command="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).ConfigureToolCommand}" CommandParameter="{Binding}" IsEnabled="{Binding IsEnabled}" Grid.Column="1" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</Grid>
</DockPanel>
</UserControl>
@@ -0,0 +1,46 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AgentsPageView : UserControl
{
public AgentsPageView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AgentsPageViewModel vm)
{
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
vm.RequestToolSettingsDialog += OnRequestToolSettingsDialog;
}
}
private async Task<AddAgentResultData?> OnRequestAddAgentDialog(AddAgentViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new AddAgentWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
}
private async Task<Dictionary<string, object?>?> OnRequestToolSettingsDialog(ToolSettingsViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new ToolSettingsWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
}
}
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.BackupPageView"
x:DataType="vm:BackupPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<ScrollViewer>
<StackPanel Spacing="16">
<TextBlock Text="Sicherung &amp; Wiederherstellung" Classes="heading" FontSize="20" />
<!-- Card: Neue Sicherung erstellen -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Manuelle Sicherung erstellen" Classes="heading" />
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto">
<TextBox Text="{Binding TargetFolder}" Grid.Column="0" PlaceholderText="Zielordner für Sicherungen..." />
<Button Content="Ordner wählen" Click="OnBrowseTargetFolder" Grid.Column="1" Margin="8,0,0,0" />
</Grid>
<!-- Zugangsdaten Optionen -->
<TextBlock Text="Zugangsdaten (API-Schlüssel, Passwörter):" FontWeight="Bold" Margin="0,4,0,0" />
<RadioButton Content="Zugangsdaten NICHT mit sichern (ohne Passphrase)" IsChecked="{Binding SecretsExclude}" />
<RadioButton Content="Mit Passphrase verschlüsseln" IsChecked="{Binding SecretsPassphrase}" />
<StackPanel Spacing="8" IsVisible="{Binding SecretsPassphrase}" Margin="20,0,0,0">
<TextBox Text="{Binding Passphrase}" PasswordChar="*" PlaceholderText="Passphrase eingeben" Width="300" HorizontalAlignment="Left" />
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="*" PlaceholderText="Passphrase wiederholen" Width="300" HorizontalAlignment="Left" />
</StackPanel>
<!-- Inhalt Optionen -->
<CheckBox Content="Protokolle (Logs) mit einschließen" IsChecked="{Binding IncludeLogs}" />
<CheckBox Content="Chatverläufe mit einschließen" IsChecked="{Binding IncludeChatHistory}" />
<Button Content="▶ Sicherung jetzt erstellen" Command="{Binding CreateBackupCommand}"
IsEnabled="{Binding !IsBusy}" Classes="accent" HorizontalAlignment="Left" Margin="0,8,0,0" />
</StackPanel>
</Border>
<!-- Card: Automatische Sicherung -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Automatische Sicherung" Classes="heading" />
<CheckBox Content="Automatische tägliche Sicherung aktivieren" IsChecked="{Binding AutoBackupEnabled}" />
<StackPanel Orientation="Horizontal" Spacing="16" IsEnabled="{Binding AutoBackupEnabled}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Uhrzeit:" VerticalAlignment="Center" />
<TextBox Text="{Binding AutoBackupTime}" Width="80" PlaceholderText="03:00" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Max. Anzahl behalten:" VerticalAlignment="Center" />
<NumericUpDown Value="{Binding KeepCount}" Minimum="1" Maximum="999" Width="120" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<!-- Vorhandene Sicherungen List -->
<Border Classes="card">
<DockPanel Height="260">
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<TextBlock Text="Vorhandene Sicherungen" Classes="heading" VerticalAlignment="Center" />
<Button Content="Aktualisieren" Command="{Binding RefreshBackupListCommand}" Margin="12,0,0,0" />
<Button Content="Ordner öffnen" Command="{Binding ShowSelectedInFolderCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Löschen" Command="{Binding DeleteSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Wiederherstellen..." Command="{Binding RestoreSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" Classes="accent" />
</StackPanel>
<DataGrid ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}" AutoGenerateColumns="False" IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Dateiname" Binding="{Binding FileName}" Width="220" />
<DataGridTextColumn Header="Erstellt am" Binding="{Binding LastWriteTime, StringFormat='{}{0:g}'}" Width="150" />
<DataGridTextColumn Header="Instanz" Binding="{Binding InstanceName}" Width="130" />
<DataGridTextColumn Header="Größe" Binding="{Binding SizeFormatted}" Width="100" />
<DataGridTextColumn Header="Zugangsdaten" Binding="{Binding SecretsInfo}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,56 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class BackupPageView : UserControl
{
public BackupPageView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is BackupPageViewModel vm)
{
vm.RequestRestoreDialog += OnRequestRestoreDialog;
}
}
private async Task<RestoreResultData?> OnRequestRestoreDialog(RestoreBackupViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new RestoreBackupWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
}
private async void OnBrowseTargetFolder(object? sender, RoutedEventArgs e)
{
if (DataContext is not BackupPageViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is null) return;
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zielordner für Sicherungen wählen",
AllowMultiple = false
});
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
{
vm.TargetFolder = path;
vm.SaveSettings();
vm.RefreshBackupListCommand.Execute(null);
}
}
}
@@ -0,0 +1,74 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.ChatPageView"
x:DataType="vm:ChatPageViewModel">
<DockPanel>
<!-- Statusleiste am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<!-- Top Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<TextBlock Text="Agent:" VerticalAlignment="Center" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}" MinWidth="200">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Verlauf leeren" Command="{Binding ClearHistoryCommand}" Margin="12,0,0,0" />
<Button Content="Abbrechen" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}" />
</StackPanel>
<!-- Eingabezeile unten -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
<TextBox Text="{Binding InputPrompt}" PlaceholderText="Nachricht eingeben... (Strg+Eingabe zum Senden)"
AcceptsReturn="True" Height="60" TextWrapping="Wrap" Grid.Column="0" KeyDown="OnInputKeyDown" />
<Button Content="Senden" Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
Classes="accent" VerticalAlignment="Stretch" Margin="8,0,0,0" Grid.Column="1" Width="90" />
</Grid>
<!-- Nachrichtenverlauf (Mitte) -->
<ScrollViewer x:Name="ChatScroller">
<ItemsControl ItemsSource="{Binding ChatEntries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ChatEntryItemViewModel">
<!-- Distinct styling: User right-aligned bubble vs Assistant left-aligned card -->
<DockPanel HorizontalAlignment="{Binding IsUser, Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<Border Classes="card" CornerRadius="8" MaxWidth="680"
Background="{Binding IsUser, Converter={x:Static conv:ChatBubbleBrushConverter.Instance}, TargetNullValue={x:Null}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock Text="{Binding Sender}" FontWeight="Bold" FontSize="12" />
<TextBlock Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" Classes="caption" HorizontalAlignment="Right" Margin="8,0,0,0" />
</DockPanel>
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13" />
</StackPanel>
</Border>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class ChatPageView : UserControl
{
public ChatPageView()
{
InitializeComponent();
}
private void OnInputKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
if (DataContext is ChatPageViewModel vm && vm.SendMessageCommand.CanExecute(null))
{
vm.SendMessageCommand.Execute(null);
e.Handled = true;
}
}
}
}
@@ -0,0 +1,71 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="ClawdDotNet.Desktop.Views.InfoPageView"
x:DataType="vm:InfoPageViewModel">
<ScrollViewer Padding="16">
<StackPanel Spacing="16">
<!-- Titel -->
<TextBlock Text="Software- &amp; Instanz-Informationen" Classes="heading" FontSize="20" />
<!-- Anwendungs-Details Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Anwendung" Classes="heading" />
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="140,*">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version:" FontWeight="Bold" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Build-Summary:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}" Margin="0,4,0,0" TextWrapping="Wrap" />
</Grid>
</StackPanel>
</Border>
<!-- Instanz-Details Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="140,*">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name:" FontWeight="Bold" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="ID:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding InstanceId}" Margin="0,4,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Pfad:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}" Margin="0,4,0,0" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Log-Ordner:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}" Margin="0,4,0,0" TextWrapping="Wrap" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Status:" FontWeight="Bold" Margin="0,4,0,0" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,4,0,0" />
</Grid>
</StackPanel>
</Border>
<!-- Assembly Build Info Card -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Module &amp; Baugruppen" Classes="heading" />
<DataGrid ItemsSource="{Binding Assemblies}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Modul" Binding="{Binding ShortName}" Width="160" />
<DataGridTextColumn Header="Build #" Binding="{Binding BuildNumber}" Width="80" />
<DataGridTextColumn Header="Build-Datum" Binding="{Binding BuildDate}" Width="160" />
<DataGridTextColumn Header="Änderungen" Binding="{Binding Changes}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class InfoPageView : UserControl
{
public InfoPageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,73 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:models="using:ClawdDotNet.App.Models"
mc:Ignorable="d" d:DesignWidth="720" d:DesignHeight="460"
x:Class="ClawdDotNet.Desktop.Views.InstancePickerWindow"
x:DataType="vm:InstancePickerViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet – Instanz wählen"
Width="720" Height="460"
WindowStartupLocation="CenterScreen">
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Classes="heading" Text="Instanz wählen" />
<TextBlock DockPanel.Dock="Top" Classes="caption" Margin="0,0,0,12"
TextWrapping="Wrap"
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
<TextBlock DockPanel.Dock="Bottom"
Margin="0,8,0,0"
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{Binding ErrorText}"
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Aktionen -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto,Auto" Margin="0,12,0,0">
<StackPanel Orientation="Horizontal" Spacing="6" Grid.Column="0">
<TextBox Width="200"
Watermark="Name der neuen Instanz"
Text="{Binding NewInstanceName}" />
<Button Content="Anlegen" Command="{Binding CreateCommand}" />
</StackPanel>
<Button Grid.Column="1" Content="Abbrechen" Margin="0,0,6,0"
Command="{Binding CancelCommand}" />
<Button Grid.Column="2" Content="Starten"
Classes="accent"
IsDefault="True"
Command="{Binding StartCommand}" />
</Grid>
<!-- Liste -->
<DataGrid ItemsSource="{Binding Instances}"
SelectedItem="{Binding Selected}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Instanzname" Width="2*"
Binding="{Binding InstanceName, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="Ordner" Width="2*"
Binding="{Binding FolderName, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="Agenten" Width="Auto"
Binding="{Binding AgentCount, x:DataType=models:InstanceInfo}" />
<DataGridTextColumn Header="API-Key" Width="Auto"
Binding="{Binding ApiKeyStatus, x:DataType=models:InstanceInfo}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Window>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class InstancePickerWindow : Window
{
public InstancePickerWindow()
{
InitializeComponent();
// Doppelklick auf eine Zeile startet — wie in der WinForms-Fassung. Das laesst
// sich nicht sinnvoll als Befehl binden, weil DataGrid kein DoubleTapped je
// Zeile anbietet; deshalb hier und nicht im Ansichtsmodell.
AddHandler(DoubleTappedEvent, OnDoubleTapped, handledEventsToo: false);
}
private void OnDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is InstancePickerViewModel vm && vm.StartCommand.CanExecute(null))
vm.StartCommand.Execute(null);
}
}
@@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="320"
x:Class="ClawdDotNet.Desktop.Views.LicenseWindow"
x:DataType="vm:LicenseViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet – Lizenz"
Width="520" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" Text="Lizenzschlüssel" />
<!-- Nur sichtbar, wenn es einen Grund gibt -->
<Border Classes="card"
IsVisible="{Binding Problem, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Problem}" TextWrapping="Wrap"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</Border>
<TextBox Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
Text="{Binding Key}"
FontFamily="{DynamicResource ContentControlThemeFontFamily}" />
<StackPanel Spacing="4">
<TextBlock Classes="caption" Text="Hardware-ID (für den Support):" />
<SelectableTextBlock Classes="caption"
Text="{Binding HardwareId}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="Beenden" Command="{Binding CancelCommand}" />
<Button Content="Prüfen" Classes="accent" IsDefault="True"
Command="{Binding ConfirmCommand}" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,16 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class LicenseWindow : Window
{
public LicenseWindow()
{
InitializeComponent();
// Wer das Fenster ueber das Kreuz schliesst, hat abgebrochen. Ohne das bliebe
// der wartende Task fuer immer offen und der Start haenge.
Closed += (_, _) => (DataContext as LicenseViewModel)?.CancelIfPending();
}
}
@@ -0,0 +1,67 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:svc="using:ClawdDotNet.App.Services"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="ClawdDotNet.Desktop.Views.LogPageView"
x:DataType="vm:LogPageViewModel">
<DockPanel>
<!-- Werkzeugleiste -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<TextBlock Text="Modul:" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding Modules}"
SelectedItem="{Binding SelectedModule}"
MinWidth="180" />
<TextBlock Text="Stufe:" VerticalAlignment="Center" Margin="12,0,0,0" />
<ComboBox ItemsSource="{Binding Levels}"
SelectedItem="{Binding SelectedLevel}"
MinWidth="100" />
<Button Content="Aktualisieren" Command="{Binding RefreshModulesCommand}" Margin="12,0,0,0" />
<Button Content="Leeren" Command="{Binding ClearCommand}" />
<Button Content="Ordner öffnen" Command="{Binding OpenFolderCommand}" />
<CheckBox Content="Mitlaufen" IsChecked="{Binding FollowTail}"
VerticalAlignment="Center" Margin="12,0,0,0" />
</StackPanel>
<!--
SelectableTextBlock je Zeile statt eines grossen TextBlocks: So laesst sich eine
einzelne Zeile herauskopieren, und die Virtualisierung des ItemsControl haelt auch
bei 2000 Zeilen die Darstellung fluessig.
-->
<ScrollViewer Name="Scroller" Padding="8">
<ItemsControl ItemsSource="{Binding Lines}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svc:LogLine">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="Consolas, Menlo, DejaVu Sans Mono, monospace"
FontSize="12"
TextWrapping="NoWrap"
Foreground="{Binding Level,
Converter={x:Static conv:LogLevelBrushConverter.Instance},
TargetNullValue={x:Null}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class LogPageView : UserControl
{
public LogPageView()
{
InitializeComponent();
// Mitlaufen: Nach jeder neuen Zeile ans Ende springen — aber nur, solange der
// Haken gesetzt ist. Das gehoert in die Ansicht, weil nur sie den ScrollViewer
// kennt; das Ansichtsmodell haelt lediglich den Schalter.
Scroller.ScrollChanged += (_, _) =>
{
if (DataContext is LogPageViewModel { FollowTail: true })
Scroller.ScrollToEnd();
};
}
}
@@ -0,0 +1,72 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet"
Width="1100" Height="700"
MinWidth="800" MinHeight="500">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<DockPanel>
<!-- Haupt-Menüleiste -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="_Datei">
<MenuItem Header="_Beenden" Command="{Binding ExitCommand}" InputGesture="Alt+F4" />
</MenuItem>
<MenuItem Header="_Bearbeiten">
<MenuItem Header="_Instanz-Ordner öffnen" Command="{Binding OpenInstanceFolderCommand}" />
<MenuItem Header="_Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
</MenuItem>
<MenuItem Header="_Instanz">
<MenuItem Header="_Instanz-Manager..." Command="{Binding OpenInstanceManagerCommand}" />
</MenuItem>
<MenuItem Header="_Ansicht">
<MenuItem Header="_Chat" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Chat" />
<MenuItem Header="_Logs" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Logs" />
<MenuItem Header="_Agenten" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Agenten" />
<MenuItem Header="_Einstellungen" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Einstellungen" />
<MenuItem Header="_Aufgaben" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Aufgaben" />
<MenuItem Header="_Sicherung" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Sicherung" />
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
</MenuItem>
<MenuItem Header="_Hilfe">
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
</MenuItem>
</Menu>
<!-- Statusleiste -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Classes="caption" Text="{Binding StatusText}" />
</Border>
<!-- Bereiche (Seitenleiste links) -->
<TabControl ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
TabStripPlacement="Left">
<TabControl.ItemTemplate>
<DataTemplate DataType="vm:PageViewModel">
<TextBlock Text="{Binding Title}" Margin="4,8" />
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate DataType="vm:PageViewModel">
<ContentControl Content="{Binding}" />
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,28 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel vm)
{
vm.RequestExit += Close;
vm.RequestInstanceManagerDialog += OnOpenInstanceManager;
}
}
private async void OnOpenInstanceManager()
{
var dialog = new InstancePickerWindow();
await dialog.ShowDialog(this);
}
}
@@ -0,0 +1,19 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="400"
x:Class="ClawdDotNet.Desktop.Views.PlaceholderPageView"
x:DataType="vm:PlaceholderPageViewModel">
<Border Classes="card" Margin="16" VerticalAlignment="Top">
<StackPanel Spacing="6">
<TextBlock Classes="heading" Text="{Binding Title}" />
<TextBlock Classes="caption" Text="{Binding Note}" TextWrapping="Wrap" />
<TextBlock Classes="caption" Margin="0,8,0,0"
Text="Dieser Bereich ist noch nicht portiert." />
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class PlaceholderPageView : UserControl
{
public PlaceholderPageView() => InitializeComponent();
}
@@ -0,0 +1,66 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400"
Width="640" Height="420"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Sicherung wiederherstellen"
x:Class="ClawdDotNet.Desktop.Views.RestoreBackupWindow"
x:DataType="vm:RestoreBackupViewModel">
<DockPanel>
<!-- Buttons unten -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Wiederherstellen" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<!-- Statusmeldung unten -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" TextWrapping="Wrap" />
<ScrollViewer>
<StackPanel Spacing="12">
<!-- Information -->
<Border Classes="card">
<StackPanel Spacing="4">
<TextBlock Text="Sicherungsinformationen" Classes="heading" />
<TextBlock Text="{Binding InstanceName, StringFormat='Instanz: {0}'}" />
<TextBlock Text="{Binding CreatedAt, StringFormat='Erstellt: {0:g}'}" />
<TextBlock Text="{Binding FileCount, StringFormat='Dateien: {0}'}" />
<TextBlock Text="{Binding SecretsDescription, StringFormat='Zugangsdaten: {0}'}" />
</StackPanel>
</Border>
<!-- Zielordner -->
<StackPanel Spacing="4">
<TextBlock Text="Zielordner:" FontWeight="Bold" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Text="{Binding TargetDirectory}" Grid.Column="0" />
<Button Content="Ordner wählen" Click="OnBrowseFolder" Grid.Column="1" Margin="8,0,0,0" />
</Grid>
<TextBlock Text="Hinweis: Es wird empfohlen, in einen neuen Ordner wiederherzustellen, um Konflikte mit laufenden Prozessen zu vermeiden."
Classes="caption" TextWrapping="Wrap" />
</StackPanel>
<!-- Passphrase -->
<StackPanel Spacing="4" IsVisible="{Binding NeedsPassphrase}">
<TextBlock Text="Passphrase:" FontWeight="Bold" />
<TextBox Text="{Binding Passphrase}" PasswordChar="*" />
<TextBlock Text="Diese Sicherung enthält geschützte Zugangsdaten." Classes="caption" />
</StackPanel>
<!-- Überschreiben -->
<CheckBox Content="Vorhandene Dateien im Zielordner überschreiben" IsChecked="{Binding Overwrite}" Margin="0,8,0,0" />
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,42 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class RestoreBackupWindow : Window
{
public RestoreBackupWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is RestoreBackupViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
{
if (DataContext is not RestoreBackupViewModel vm) return;
var storage = StorageProvider;
if (storage is null) return;
var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Zielordner für Wiederherstellung wählen",
AllowMultiple = false
});
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
{
vm.TargetDirectory = path;
}
}
}
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.SettingsPageView"
x:DataType="vm:SettingsPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Verwerfen / Zurücksetzen" Command="{Binding LoadFromHostCommand}" />
<Button Content="Speichern" Command="{Binding SaveSettingsCommand}" Classes="accent" />
</StackPanel>
</Border>
<ScrollViewer>
<StackPanel Spacing="16">
<TextBlock Text="Anwendungs- &amp; Instanz-Einstellungen" Classes="heading" FontSize="20" />
<!-- Card: Instanz-Einstellungen -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Instanz-Name:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="OpenRouter API-Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding OpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Tagesbudget ($ USD):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding InstanceDailyCostUsd}" Minimum="0" Maximum="10000" FormatString="F2" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tagesbudget (Tokens):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding InstanceDailyTokens}" Minimum="0" Maximum="100000000" FormatString="0" Width="180" HorizontalAlignment="Left" Margin="0,8,0,0" />
</Grid>
</StackPanel>
</Border>
<!-- Card: Allgemein & Pfade -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Allgemein &amp; Pfade" Classes="heading" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Log-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding LogDirectory}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Instanzen-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding InstancesDirectory}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Minimaler Log-Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="OpenRouter Base-URL:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding OpenRouterBaseUrl}" Margin="0,8,0,0" />
</Grid>
</StackPanel>
</Border>
<!-- Card: Deploymentcenter & Lizenz -->
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Text="Deploymentcenter &amp; Lizenz" Classes="heading" />
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Server-URL:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DeploymentcenterUrl}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Master-Token:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DeploymentcenterToken}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Update-Kanal:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Umgebung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="3" Grid.Column="1" ItemsSource="{Binding Environments}" SelectedItem="{Binding DeploymentcenterEnvironment}" Width="160" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Lizenzschlüssel:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding LicenseKey}" PasswordChar="*" Margin="0,8,0,0" />
</Grid>
<CheckBox Content="Fehler automatisch an Deploymentcenter melden" IsChecked="{Binding ErrorReportingEnabled}" Margin="0,4,0,0" />
<CheckBox Content="Beim Start automatisch auf Updates prüfen" IsChecked="{Binding UpdateCheckEnabled}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class SettingsPageView : UserControl
{
public SettingsPageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,102 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.TasksPageView"
x:DataType="vm:TasksPageViewModel">
<DockPanel>
<!-- Statusbar unten -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<TabControl>
<!-- TAB 1: Jobs (Scheduled / Worker) -->
<TabItem Header="Geplante Jobs">
<DockPanel Margin="0,8,0,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Job hinzufügen" Command="{Binding AddJobCommand}" Classes="accent" />
<Button Content="Bearbeiten" Command="{Binding EditJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Entfernen" Command="{Binding RemoveJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="▶ Jetzt ausführen" Command="{Binding RunJobNowCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" Margin="12,0,0,0" />
<Button Content="Aktualisieren" Command="{Binding RefreshJobListCommand}" />
</StackPanel>
<!-- Jobs DataGrid -->
<DataGrid ItemsSource="{Binding JobEntries}" SelectedItem="{Binding SelectedJob}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Typ" Binding="{Binding JobType}" Width="120" />
<DataGridTextColumn Header="Agent" Binding="{Binding AgentName}" Width="150" />
<DataGridTextColumn Header="Tool / Nachricht" Binding="{Binding TaskMessage}" Width="*" />
<DataGridTextColumn Header="Cron-Ausdruck" Binding="{Binding CronExpression}" Width="120" />
<DataGridTextColumn Header="Nächster Lauf" Binding="{Binding NextRun}" Width="140" />
<DataGridTextColumn Header="Letzter Lauf" Binding="{Binding LastRun}" Width="140" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<!-- TAB 2: Dienste (Services) -->
<TabItem Header="Dienste (Services)">
<DockPanel Margin="0,8,0,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Dienst hinzufügen" Command="{Binding AddServiceCommand}" Classes="accent" />
<Button Content="Entfernen" Command="{Binding RemoveServiceCommand}" IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Aktualisieren" Command="{Binding RefreshServiceListCommand}" />
</StackPanel>
<!-- Services DataGrid -->
<DataGrid ItemsSource="{Binding ServiceEntries}" SelectedItem="{Binding SelectedService}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Dienstname" Binding="{Binding Name}" Width="180" />
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="100" />
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="80" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="110" />
<DataGridTextColumn Header="Beschreibung" Binding="{Binding Description}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<!-- TAB 3: Verlauf (Job History) -->
<TabItem Header="Verlauf (History)">
<DockPanel Margin="0,8,0,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Verlauf aktualisieren" Command="{Binding RefreshJobHistoryCommand}" />
</StackPanel>
<!-- History DataGrid -->
<DataGrid ItemsSource="{Binding JobHistoryEntries}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeitpunkt" Binding="{Binding Time, StringFormat='{}{0:g}'}" Width="140" />
<DataGridTextColumn Header="Job-Name" Binding="{Binding JobName}" Width="140" />
<DataGridTextColumn Header="Agent" Binding="{Binding Agent}" Width="140" />
<DataGridTextColumn Header="Beschreibung" Binding="{Binding JobDescription}" Width="220" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
<DataGridTextColumn Header="Details / Info" Binding="{Binding Info}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>
@@ -0,0 +1,46 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class TasksPageView : UserControl
{
public TasksPageView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is TasksPageViewModel vm)
{
vm.RequestAddJobDialog += OnRequestAddJobDialog;
vm.RequestAddServiceDialog += OnRequestAddServiceDialog;
}
}
private async Task<AddJobResultData?> OnRequestAddJobDialog(AddJobViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new AddJobWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
}
private async Task<AddServiceResultData?> OnRequestAddServiceDialog(AddServiceViewModel vm)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window window)
{
var dialog = new AddServiceWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
return null;
}
}
@@ -0,0 +1,180 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
xmlns:models="using:ClawdDotNet.Models"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="560" d:DesignHeight="480"
Width="580" Height="500"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Werkzeug-Einstellungen"
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsWindow"
x:DataType="vm:ToolSettingsViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Speichern" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<ScrollViewer>
<StackPanel Spacing="12">
<TextBlock Text="{Binding ToolName, StringFormat='Einstellungen für {0}'}" Classes="heading" FontSize="18" />
<!-- Forms for each Tool -->
<StackPanel Spacing="8">
<!-- 1. FTP -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Host:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding FtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FtpUsername}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FtpPassword}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Root-Pfad:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding FtpRootPath}" Margin="0,8,0,0" />
</Grid>
<!-- 2. Mail -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding MailUsername}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding MailPassword}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="IMAP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding MailImapHost}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="IMAP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding MailImapPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="SMTP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding MailSmtpHost}" Margin="0,8,0,0" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="SMTP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding MailSmtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="Erlaubte Empfänger:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding MailAllowedRecipients}" PlaceholderText="email1@domain.de, email2@domain.de" Margin="0,8,0,0" />
</Grid>
<!-- 3. Database -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Typ:" FontWeight="Bold" VerticalAlignment="Center" />
<ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}" HorizontalAlignment="Stretch" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection-String:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DbConnectionString}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Zugriffsebene:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Erlaubte Tabellen:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" Margin="0,8,0,0" />
</Grid>
<!-- 4. Telegram -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bot-Token:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding TelegramBotToken}" PasswordChar="*" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Standard Chat-ID:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TelegramDefaultChatId}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Erlaubte Chat-IDs:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" Margin="0,8,0,0" />
</Grid>
<!-- 5. DirectAPI -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Standard-Provider:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DirectApiDefaultProvider}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Cache TTL (Sek.):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding DirectApiCacheTtlSeconds}" Minimum="0" Maximum="86400" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Twelve Data Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding DirectApiTwelveDataKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Alpha Vantage Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="*" Margin="0,8,0,0" />
</Grid>
<!-- 6. WebFetch -->
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Erlaubte Domains:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding WebFetchAllowedDomains}" PlaceholderText="reuters.com, bloomberg.com" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Max Response KB:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding WebFetchMaxResponseKb}" Minimum="1" Maximum="10000" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="User Agent:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding WebFetchUserAgent}" Margin="0,8,0,0" />
</Grid>
<!-- 7. SocialMediaManager -->
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="X (Twitter) API-Key:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding SmmXApiKey}" PasswordChar="*" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="X Accounts:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding SmmXWatchAccounts}" PlaceholderText="elonmusk, unusual_whales" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Subreddits:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="wallstreetbets, stocks" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Reddit Post Limit:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding SmmRedditPostLimit}" Minimum="1" Maximum="100" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding SmmOpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="STT Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding SmmSttModel}" Margin="0,8,0,0" />
<TextBlock Grid.Row="6" Grid.Column="0" Text="YouTube Kanäle:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" Margin="0,8,0,0" />
</Grid>
<!-- 8. FileRW -->
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Personal Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FileRwPersonalAllowedExtensions}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Shared Access Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding FileRwAccessLevels}" SelectedItem="{Binding FileRwSharedAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Shared Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FileRwSharedAllowedExtensions}" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Geschützte Pfade:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FileRwProtectedPaths}" Margin="0,8,0,0" />
</Grid>
<!-- Fallback info for tools without extra config -->
<TextBlock Text="Dieses Werkzeug benötigt keine weiteren Einstellungen."
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}"
FontStyle="Italic" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class ToolSettingsWindow : Window
{
public ToolSettingsWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ToolSettingsViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}