UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4

Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 33d95a6c3f
88 changed files with 11665 additions and 1029 deletions
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
@@ -11,12 +12,20 @@ public sealed partial class ToolItemViewModel : ObservableObject
{
public string Name { get; }
/// <summary>
/// Kurzbeschreibung aus der Werkzeug-Registrierung. Leer, wenn das Werkzeug in dieser
/// Instanz gar nicht geladen ist — die Liste der bekannten Namen ist länger als die
/// der tatsächlich vorhandenen Werkzeuge.
/// </summary>
public string Description { get; }
[ObservableProperty]
private bool _isEnabled;
public ToolItemViewModel(string name, bool isEnabled)
public ToolItemViewModel(string name, bool isEnabled, string description = "")
{
Name = name;
Description = description;
_isEnabled = isEnabled;
}
}
@@ -75,7 +84,7 @@ public sealed partial class AgentItemViewModel : ObservableObject
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AgentItemViewModel(AgentConfig config)
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
Config = config;
_displayName = config.DisplayName;
@@ -95,7 +104,9 @@ public sealed partial class AgentItemViewModel : ObservableObject
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var item = new ToolItemViewModel(tool, isEnabled);
var description = descriptions?.GetValueOrDefault(tool) ?? "";
var item = new ToolItemViewModel(tool, isEnabled, description);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
@@ -128,8 +139,21 @@ public sealed partial class AgentItemViewModel : ObservableObject
}
}
/// <summary>Welches Feld der Texteditor gerade bearbeitet.</summary>
public enum AgentTextField
{
Identity,
Soul
}
/// <summary>
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
///
/// <para>Der Entwurf ordnet die Seite wieder wie die WinForms-Vorlage in vier Felder an:
/// oben Agentenliste und Agenteneinstellungen, unten Werkzeugliste und
/// Werkzeugeinstellungen. Für dieses Modell heißt das vor allem: Die Werkzeugeinstellungen
/// sind kein eigenes Fenster mehr, sondern hängen als <see cref="ToolSettings"/> an der
/// Auswahl in der Werkzeugliste.</para>
/// </summary>
public sealed partial class AgentsPageViewModel : PageViewModel
{
@@ -141,9 +165,27 @@ public sealed partial class AgentsPageViewModel : PageViewModel
[ObservableProperty]
private AgentItemViewModel? _selectedAgent;
/// <summary>
/// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten
/// rechts stehen.
/// </summary>
[ObservableProperty]
private ToolItemViewModel? _selectedTool;
/// <summary>
/// Die Felder zum ausgewählten Werkzeug. Wird bei jedem Wechsel neu gebaut — das
/// Ansichtsmodell trägt die Werte von einem guten Dutzend Werkzeuge, und eines davon
/// mit den Werten eines anderen weiterzuverwenden ginge irgendwann schief.
/// </summary>
[ObservableProperty]
private ToolSettingsViewModel? _toolSettings;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunning;
public List<string> KnownModels { get; } =
[
"anthropic/claude-3-5-sonnet",
@@ -158,7 +200,12 @@ public sealed partial class AgentsPageViewModel : PageViewModel
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
public event Func<ToolSettingsViewModel, Task<Dictionary<string, object?>?>>? RequestToolSettingsDialog;
/// <summary>
/// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück,
/// wurde gespeichert; <c>null</c> heißt Abbruch.
/// </summary>
public event Func<string, string, Task<string?>>? RequestTextEditorDialog;
public AgentsPageViewModel() : this(null) { }
@@ -177,20 +224,55 @@ public sealed partial class AgentsPageViewModel : PageViewModel
}
}
/// <summary>
/// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in
/// <see cref="AgentItemViewModel.KnownTools"/> ist fest verdrahtet und kennt auch
/// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die
/// Beschreibung eben leer.
/// </summary>
private IReadOnlyDictionary<string, string> ToolDescriptions()
=> _host?.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>();
[RelayCommand]
private void ReloadAgents()
{
Agents.Clear();
if (_host is null) return;
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
{
Agents.Add(new AgentItemViewModel(agent));
}
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault();
}
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
{
// Die Werkzeugliste unten gehört zum gewählten Agenten — bei einem Wechsel darf
// dort nicht die Auswahl des vorigen stehen bleiben.
SelectedTool = value?.AvailableTools.FirstOrDefault();
}
partial void OnSelectedToolChanged(ToolItemViewModel? value)
{
if (SelectedAgent is null || value is null)
{
ToolSettings = null;
return;
}
// Auch für ein noch nicht zugelassenes Werkzeug sollen die Felder sichtbar sein —
// sonst müsste man erst ankreuzen, um zu sehen, was einzustellen wäre.
if (!SelectedAgent.Config.Tools.TryGetValue(value.Name, out var config) || config is null)
config = new Dictionary<string, object?>();
ToolSettings = new ToolSettingsViewModel(value.Name, config);
}
[RelayCommand]
private async Task AddAgentAsync()
{
@@ -217,7 +299,7 @@ public sealed partial class AgentsPageViewModel : PageViewModel
_host.Instance.Agents.Add(newConfig);
SaveAgents();
var item = new AgentItemViewModel(newConfig);
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
@@ -239,28 +321,118 @@ public sealed partial class AgentsPageViewModel : PageViewModel
StatusText = $"Agent '{agentId}' entfernt.";
}
/// <summary>
/// Weckt den gewählten Agenten von Hand — im Entwurf die Schaltfläche „Jetzt ausf.".
/// Nimmt dieselbe Nachricht wie der geplante Lauf, damit der Handlauf zeigt, was der
/// geplante täte.
/// </summary>
[RelayCommand]
private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
private async Task RunAgentNowAsync()
{
if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
if (_host is null || SelectedAgent is null) return;
var toolName = toolItem.Name;
if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
if (_host.Engine is null)
{
config = new Dictionary<string, object?>();
SelectedAgent.Config.Tools[toolName] = config;
toolItem.IsEnabled = true;
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var vm = new ToolSettingsViewModel(toolName, config);
var newConfig = await RequestToolSettingsDialog(vm);
var config = SelectedAgent.Config;
var message = config.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
if (newConfig is not null)
IsRunning = true;
StatusText = $"Führe '{config.DisplayName}' aus…";
try
{
SelectedAgent.Config.Tools[toolName] = newConfig;
SaveAgents();
StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
var result = await _host.Engine.RunAsync(
config, message, _host.Instance.InstanceId, CancellationToken.None);
StatusText = $"'{config.DisplayName}' abgeschlossen: {result.Status} "
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Lauf fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Handlauf des Agenten fehlgeschlagen");
}
finally
{
IsRunning = false;
}
}
[RelayCommand]
private Task EditIdentityAsync() => EditTextFieldAsync(AgentTextField.Identity);
[RelayCommand]
private Task EditSoulAsync() => EditTextFieldAsync(AgentTextField.Soul);
/// <summary>
/// Identity und Soul sind lange Texte. Der Entwurf gibt ihnen ein eigenes kleines
/// Fenster statt eines Feldes in der Spalte — in einem 60 Pixel hohen Kasten lässt
/// sich eine Rollenbeschreibung nicht schreiben.
/// </summary>
private async Task EditTextFieldAsync(AgentTextField field)
{
if (SelectedAgent is null || RequestTextEditorDialog is null) return;
var isIdentity = field == AgentTextField.Identity;
var title = isIdentity
? $"Identity bearbeiten — {SelectedAgent.DisplayName}"
: $"Soul bearbeiten — {SelectedAgent.DisplayName}";
var current = isIdentity ? SelectedAgent.Identity : SelectedAgent.Soul;
var edited = await RequestTextEditorDialog(title, current);
if (edited is null) return;
if (isIdentity) SelectedAgent.Identity = edited;
else SelectedAgent.Soul = edited;
SaveAgents();
}
/// <summary>Öffnet den Arbeitsordner des gewählten Agenten.</summary>
[RelayCommand]
private void OpenAgentFolder()
{
if (_host is null || SelectedAgent is null) return;
var path = _host.Directories.GetAgentPath(_host.InstancePath, SelectedAgent.DisplayName);
if (!Directory.Exists(path))
{
StatusText = $"Ordner nicht gefunden: {path}";
return;
}
SystemShell.OpenFolder(path);
}
/// <summary>
/// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration.
///
/// <para>Das Ankreuzfeld in der Liste und diese Schaltfläche sind zwei verschiedene
/// Dinge: Ersteres lässt das Werkzeug zu, letztere schreibt seine Einstellungen. Wer
/// nur ankreuzt, bekommt ein Werkzeug mit Vorgabewerten — das ist gewollt.</para>
/// </summary>
[RelayCommand]
private void SaveToolSettings()
{
if (SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
SelectedAgent.Config.Tools[SelectedTool.Name] = config;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen.
SelectedTool.IsEnabled = true;
SaveAgents();
StatusText = $"Einstellungen für '{SelectedTool.Name}' gespeichert.";
}
[RelayCommand]
@@ -269,12 +441,46 @@ public sealed partial class AgentsPageViewModel : PageViewModel
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.";
}
// ─── Teilerstellungen ───
//
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
// Verhaeltnis, kein Pixelwert: Sonst sitzt der Teiler nach einem Wechsel auf einen
// anderen Bildschirm an einer voellig anderen Stelle.
public double SplitVertical => Clamp(_host?.Settings.AppSettings.AgentsSplitVertical ?? 0.55);
public double SplitTopList => Clamp(_host?.Settings.AppSettings.AgentsSplitTopList ?? 0.44);
public double SplitBottomList => Clamp(_host?.Settings.AppSettings.AgentsSplitBottomList ?? 0.44);
/// <summary>
/// Hält den Teiler in einem Bereich, in dem beide Seiten noch bedienbar sind. Ein
/// gespeicherter Wert von 0.02 hätte eine Spalte auf einen Strich zusammengezogen und
/// wäre ohne Zurücksetzen nicht mehr aufzuziehen gewesen.
/// </summary>
private static double Clamp(double ratio)
=> double.IsFinite(ratio) ? Math.Clamp(ratio, 0.2, 0.8) : 0.5;
public void SaveSplits(double vertical, double topList, double bottomList)
{
if (_host is null) return;
try
{
var settings = _host.Settings.AppSettings;
settings.AgentsSplitVertical = Clamp(vertical);
settings.AgentsSplitTopList = Clamp(topList);
settings.AgentsSplitBottomList = Clamp(bottomList);
_host.Settings.Save();
}
catch (Exception)
{
// Eine Teilerstellung ist es nicht wert, die Bedienung zu unterbrechen.
}
}
}