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
@@ -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;