Stufe 3 (#764): Avalonia-Fenster entfernt

- Views, ViewModels, Controls, Converters, Styles, Services, Desktop-i18n,
  Schriften und App.axaml raus; Avalonia- und Mvvm-Pakete raus
- ClawdDotNet ohne Schalter: Manager starten (unter systemd ueber den Dienst,
  nie doppelt) und WebUI oeffnen (LauncherRun) - bis die Huelle kommt
- Instanzen laufen immer kopflos; instanceWindows wird gelesen, nicht mehr
  ausgewertet; die Kette 'Fenster nach vorn holen' ist entfernt
- Telegram-Anmeldung nur noch ueber die WebUI (tote Dialog-Rueckrufe raus)
- systemd-Vorlagen im Linux-Paket; Oberflaechen-Leitfaden ins Archiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 20:23:12 +02:00
co-authored by Claude Opus 5.5
parent 8ec7896417
commit 6d81693041
104 changed files with 266 additions and 10262 deletions
+16 -35
View File
@@ -306,14 +306,10 @@ public sealed class AppHost : IAsyncDisposable
/// </summary>
public bool IsManaged { get; init; }
/// <summary>
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
/// Dienst dauerhaft blockieren würde.
/// </summary>
public Func<string, Task<string>>? TelegramLogin { get; init; }
public Func<Task<string>>? Telegram2FA { get; init; }
// #794: Hier standen TelegramLogin und Telegram2FA — Rückrufe für einen Eingabedialog.
// Null liess die MTProto-Anmeldung aus, im kopflosen Betrieb der richtige Weg, weil
// ein Eingabefenster dort einen Dienst dauerhaft blockieren würde. Die Anmeldung läuft
// jetzt über die Weboberfläche (TelegramLoginBroker) und blockiert nichts.
}
/// <summary>
@@ -496,7 +492,7 @@ public sealed class AppHost : IAsyncDisposable
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
}
await host.ConnectTelegramAsync(callbacks, logger);
host.ConnectTelegram(logger);
await host.StartDeploymentcenterAsync(loggerFactory, ct);
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
@@ -1068,35 +1064,20 @@ public sealed class AppHost : IAsyncDisposable
return AdminResult.Success("Telegram login started.");
}
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
/// <summary>
/// #794: The Telegram login runs through the web interface, in the background, so the HTTP
/// interface the code has to come in through is up first. Until #764 a desktop dialog asked
/// here and the start waited for it; that window is gone.
/// </summary>
private void ConnectTelegram(ILogger logger)
{
if (TelegramClient is null) return;
// #794: Without a dialog (every instance without a window), the login runs through
// the web interface - in the background, so the HTTP interface it needs comes up.
if (callbacks.TelegramLogin is null)
{
_telegramLogger = logger;
TelegramLogin = new TelegramLoginBroker(configured: true);
TelegramClient.OnLoginCodeRequired += prompt => TelegramLogin.AskForCodeAsync(prompt);
TelegramClient.On2FAPasswordRequired += () => TelegramLogin.AskForPasswordAsync();
StartTelegramLogin();
return;
}
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
if (callbacks.Telegram2FA is not null)
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
try
{
await TelegramClient.ConnectAsync(CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
}
_telegramLogger = logger;
TelegramLogin = new TelegramLoginBroker(configured: true);
TelegramClient.OnLoginCodeRequired += prompt => TelegramLogin.AskForCodeAsync(prompt);
TelegramClient.On2FAPasswordRequired += () => TelegramLogin.AskForPasswordAsync();
StartTelegramLogin();
}
/// <summary>
-16
View File
@@ -1,16 +0,0 @@
<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="Light">
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
</Application.Styles>
</Application>
-995
View File
@@ -1,995 +0,0 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Avalonia.Layout;
using Avalonia.Media;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Desktop.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
using ClawdDotNet.Web;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop;
public partial class App : Application
{
/// <summary>
/// Schalter der Befehlszeile, mit dem sich ClawdDotNet auf eine bestimmte Instanz
/// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der
/// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert.
/// </summary>
private const string InstanceArgument = CommandLine.InstanceArgument;
private AppHost? _host;
/// <summary>Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null.</summary>
private InstanceHttpHost? _web;
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
private string? _preselectedInstancePath;
/// <summary>
/// Vom Instanzmanager gestartet (<c>--managed</c>).
///
/// <para>Dann gehoert der Instanzwechsel dorthin: Der Manager haelt die Fenster aller
/// Instanzen im Hintergrund und holt das gewuenschte nach vorn (E-M2). Ein Fenster,
/// das sich selbst mit einer anderen Instanz neu startet, naehme genau die Instanz
/// mit, aus der heraus jemand wechselt.</para>
/// </summary>
private bool _managed;
// #576: Muessen leben, solange die Anwendung laeuft - eine eingesammelte
// Registrierung meldet sich ab.
private System.Runtime.InteropServices.PosixSignalRegistration? _sigterm;
private System.Runtime.InteropServices.PosixSignalRegistration? _sigint;
/// <summary>
/// SIGTERM und SIGINT beenden auch mit Fenster geordnet (#576).
///
/// <para>Kopflos war das seit HeadlessRun verdrahtet, mit Fenster nicht: Unter Linux
/// reagierte die Instanz auf SIGTERM gar nicht und wurde nach Ablauf jeder Frist hart
/// abgeschossen - beim Abmelden, bei systemctl stop, beim Herunterfahren. Derselbe Weg
/// wie die Bitte des Instanzmanagers: das Fenster ohne Rueckfrage schliessen, das
/// geordnete Ende laeuft von dort.</para>
/// </summary>
private void RegisterSignals(IClassicDesktopStyleApplicationLifetime desktop)
{
void Beenden(System.Runtime.InteropServices.PosixSignalContext context)
{
// Abfangen, nicht durchlassen: Sonst raeumte die Laufzeit den Prozess ab,
// waehrend das geordnete Ende noch laeuft.
context.Cancel = true;
var signal = context.Signal.ToString();
Dispatcher.UIThread.Post(() =>
{
_host?.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown")
.LogInformation("Signal {Signal} empfangen - die Instanz endet geordnet.", signal);
if (_host is null)
desktop.Shutdown();
else if (desktop.MainWindow is MainWindow fenster)
fenster.BeendenOhneRueckfrage();
else
_ = BeendeGeordnetAsync(desktop, signal);
});
}
try
{
_sigterm = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGTERM, Beenden);
_sigint = System.Runtime.InteropServices.PosixSignalRegistration.Create(
System.Runtime.InteropServices.PosixSignal.SIGINT, Beenden);
}
catch (PlatformNotSupportedException)
{
// Ohne Signale bleibt es beim Schliessen ueber Fenster und Manager.
}
}
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;
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
_managed = CommandLine.IsManaged(desktop.Args);
ApplySavedThemeEarly();
ApplySavedLanguageEarly();
desktop.ShutdownRequested += async (_, _) =>
{
// Reihenfolge: erst die Weboberflaeche, dann der Aufbau. Andersherum
// bediente Kestrel noch Anfragen gegen eine bereits geschlossene
// Datenbank.
await ShutdownWebAsync();
if (_host is not null) await _host.DisposeAsync();
};
RegisterSignals(desktop);
// 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();
}
/// <summary>
/// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint.
///
/// <para>Das Ansichtsmodell des Hauptfensters tut dasselbe — aber erst, wenn der
/// Aufbau steht. Dazwischen liegt die Instanzauswahl, und die waere sonst bei einem
/// Benutzer im Dunkelmodus hell und der Rest der Anwendung dunkel.</para>
///
/// <para>Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und
/// schreibt allenfalls beim allerersten Start die Vorgaben fest.</para>
/// </summary>
private static void ApplySavedThemeEarly()
{
try
{
var settings = new SettingsManager();
settings.Load();
ThemeManager.Apply(ThemeManager.IsDarkName(settings.AppSettings.Theme));
}
catch (Exception)
{
// Ohne lesbare Einstellungen bleibt es beim hellen Schema.
}
}
/// <summary>
/// Setzt die gespeicherte Sprache, bevor das erste Fenster erscheint.
/// </summary>
private static void ApplySavedLanguageEarly()
{
try
{
var settings = new SettingsManager();
settings.Load();
LocalizationManager.Instance.SetLanguage(settings.AppSettings.Language);
}
catch (Exception)
{
LocalizationManager.Instance.SetLanguage(LocalizationManager.DefaultLanguage);
}
}
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
var result = await AppHost.StartAsync(new AppHost.Callbacks
{
SelectInstance = SelectInstanceAsync,
License = new AvaloniaLicensePrompt(),
IsManaged = _managed,
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;
LocalizationManager.Instance.SetLanguage(_host.Settings.AppSettings.Language);
HookErrorReporting(_host);
HookLicenseWatch(_host, desktop);
// Nach dem Aufbau und vor dem Fenster: Die HTTP-Flaeche braucht den fertigen
// AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart
// gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll.
//
// Sie ist mehr als die Weboberflaeche: Immer dabei ist der Socket im
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
_web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory);
var mainViewModel = new MainWindowViewModel(_host)
{
// Wer beaufsichtigt wird, wechselt nicht selbst. Der geprüfte Befund des
// Aufbaus, nicht der Schalter der Befehlszeile (#535): Wer --managed ohne
// gueltige Unterschrift setzt, ist eine eigenstaendige Instanz und darf
// wechseln wie jede andere.
CanSwitchInstance = !_host.IsManaged
};
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
// die Lebensdauer der Anwendung, und beides kennt nur diese Klasse.
mainViewModel.RequestInstanceManagerDialog += () => _ = SwitchInstanceAsync(desktop);
desktop.MainWindow = new MainWindow { DataContext = mainViewModel };
// Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2).
if (_web is not null)
{
_web.Control.FocusRequested += () => BringToFront(desktop);
// Und er bittet, zu enden. Ohne diese Zeile lief die Bitte ins Leere: Der
// Endpunkt nahm sie an, antwortete mit 202 - und niemand hoerte zu. Der
// Manager wartete seine Frist ab und schoss den Prozess dann jedes Mal ab.
// Kopflos war das verdrahtet (HeadlessRun), mit Fenster nicht; und mit
// Fenster ist die Vorgabe (ManagerSettings.InstanceWindows).
_web.Control.ShutdownRequested += () => Dispatcher.UIThread.Post(() =>
{
if (desktop.MainWindow is MainWindow fenster)
fenster.BeendenOhneRueckfrage();
else
_ = BeendeGeordnetAsync(desktop, "Instanzmanager");
});
}
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => _ = BeendeGeordnetAsync(desktop, "Fenster");
await OfferUpdateAsync(_host, desktop);
}
/// <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>
/// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll.
///
/// <para>Bewusst eine Frage und kein Selbstlauf: 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.</para>
/// </summary>
private async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
{
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;
var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}";
var install = await ConfirmAsync(
update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
$"Version {update.LatestVersion} ist verfügbar.{notes}\n\n"
+ "Zum Einspielen wird ClawdDotNet beendet und danach neu gestartet.",
confirmText: "Jetzt installieren",
cancelText: "Später");
if (!install) return;
await StartUpdateAsync(host, desktop, update.LatestVersion);
}
/// <summary>
/// Die Reihenfolge ist der ganze Punkt dieser Methode.
///
/// <list type="number">
/// <item>Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das
/// Verschwinden der Instanz aus wie ein Ausfall.</item>
/// <item>Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung.</item>
/// <item>Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet,
/// bis wir weg sind, statt über gesperrte Dateien zu kopieren.</item>
/// <item>Selbst beenden.</item>
/// </list>
///
/// <para>Die Verlockung wäre, <c>LaunchUpdateAgent</c> das Beenden zu überlassen —
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
/// vollständig.</para>
/// </summary>
private async Task StartUpdateAsync(
AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version)
{
host.Deploymentcenter!.AnnounceUpdate(version);
var settings = host.Settings.AppSettings;
// Vor dem Aufbau: Kestrel darf keine Anfrage mehr gegen eine geschlossene
// Datenbank bedienen — und schon gar nicht, waehrend der update-agent
// gleich die Dateien darunter austauscht.
await ShutdownWebAsync();
await host.DisposeAsync();
var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion);
if (problem is not null)
{
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Update", problem);
// Der Aufbau ist bereits abgebaut — weiterarbeiten wäre nicht ehrlich.
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(1));
return;
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
/// <summary>Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit.</summary>
private static async Task<bool> ConfirmAsync(
string title, string message, string confirmText, string cancelText)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<bool>();
var confirm = new Button { Content = confirmText, IsDefault = true };
var cancel = new Button { Content = cancelText, IsCancel = true };
var window = new Window
{
Title = title,
Width = 480,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm }
}
}
}
};
var answer = false;
confirm.Click += (_, _) => { answer = true; window.Close(); };
cancel.Click += (_, _) => window.Close();
// Schließt der Benutzer das Fenster, gilt das als „Später".
window.Closed += (_, _) => completion.TrySetResult(answer);
window.Show();
return await completion.Task;
});
/// <summary>
/// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers.
///
/// <para><b>Die Instanz macht das selbst, statt dass jemand von aussen an ihrem
/// Fenster zieht.</b> Der Manager kennt kein Fenstersystem und soll keines kennen; er
/// bittet über den Socket, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft
/// die Bitte ins Leere, und das ist die richtige Antwort.</para>
///
/// <para><b>Das letzte Wort hat der Fenstermanager.</b> Windows lässt einen Prozess
/// im Hintergrund den Vordergrund nicht einfach an sich reissen — <c>Activate</c>
/// blinkt dann nur in der Leiste. Der kurze Ausflug über <c>Topmost</c> bringt es in
/// den meisten Fällen trotzdem nach vorn; unter Linux entscheidet der Fenstermanager,
/// und manche sind da streng. Mehr als bitten kann eine Anwendung nicht, und mehr
/// sollte sie auch nicht tun.</para>
/// </summary>
private static void BringToFront(IClassicDesktopStyleApplicationLifetime desktop)
=> Dispatcher.UIThread.Post(() =>
{
if (desktop.MainWindow is not { } fenster) return;
if (fenster.WindowState == WindowState.Minimized)
fenster.WindowState = WindowState.Normal;
fenster.Show();
var vorher = fenster.Topmost;
fenster.Topmost = true;
fenster.Topmost = vorher;
fenster.Activate();
});
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
///
/// <para>Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine
/// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an
/// <em>einer</em> Instanz. Statt das umzubauen startet sich die Anwendung mit
/// <c>--instance</c> neu — derselbe Weg, den auch das Update geht.</para>
///
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
/// </summary>
private enum StartModeChoice
{
LaunchManager,
Standalone,
Cancel
}
private enum StandaloneSwitchChoice
{
LaunchManager,
SwitchStandalone,
Cancel
}
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
/// </summary>
private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
if (_host is null) return;
if (_host.IsManaged)
{
await new AvaloniaLicensePrompt().ShowInfoAsync(
"ClawdDotNet – Instanzwechsel",
"Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — "
+ "er holt das Fenster der gewünschten Instanz nach vorn, statt eine "
+ "laufende zu beenden.");
return;
}
// Im Standalone-Modus den Benutzer fragen:
var switchChoice = await AskStandaloneSwitchModeAsync(desktop.MainWindow);
if (switchChoice == StandaloneSwitchChoice.LaunchManager)
{
LaunchInstanceManagerAndOpenBrowser();
return;
}
if (switchChoice == StandaloneSwitchChoice.Cancel)
{
return;
}
var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
var window = new InstancePickerWindow { DataContext = viewModel };
// Schliesst der Benutzer das Fenster, gilt das als Abbruch.
window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
// Umgekehrt: Sobald eine Wahl feststeht, schliesst sich das Fenster von selbst.
_ = viewModel.Result.ContinueWith(
_ => Dispatcher.UIThread.Post(window.Close), TaskScheduler.Default);
if (desktop.MainWindow is { } owner) await window.ShowDialog(owner);
else window.Show();
var path = await viewModel.Result;
if (path is null) return;
if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return;
var executable = Environment.ProcessPath;
if (executable is null)
{
await new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzwechsel",
"Der eigene Programmpfad ist nicht ermittelbar; ein Neustart ist von hier "
+ "aus nicht möglich. Bitte ClawdDotNet von Hand neu starten.");
return;
}
// Neue Instanz mit UseShellExecute = true starten (keine geerbten Datei- oder Socket-Handles)
try
{
Process.Start(new ProcessStartInfo(executable)
{
ArgumentList = { InstanceArgument, path },
UseShellExecute = true
});
}
catch (Exception ex)
{
await new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzwechsel",
$"Neustart fehlgeschlagen: {ex.Message}");
return;
}
// Hauptfenster sofort verbergen, damit es nicht eingefroren sichtbar bleibt
if (desktop.MainWindow is { } mainWin)
{
mainWin.Hide();
}
await ShutdownWebAsync();
var hostToDispose = _host;
_host = null;
if (hostToDispose is not null)
{
await hostToDispose.DisposeAsync();
}
// Sauber beenden
await Dispatcher.UIThread.InvokeAsync(() =>
{
desktop.Shutdown(0);
Environment.Exit(0);
});
}
private static void LaunchInstanceManagerAndOpenBrowser()
{
var exePath = FindInstanceManagerExecutable();
if (exePath is null)
{
_ = new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzmanager",
"Die Programmdatei des Instanzmanagers (ClawdDotNet.Instanzmanager.exe) wurde nicht gefunden.");
return;
}
try
{
Process.Start(new ProcessStartInfo(exePath)
{
UseShellExecute = true
});
SystemShell.OpenUrl($"http://localhost:{ManagerPort()}/#/instanzen");
}
catch (Exception ex)
{
_ = new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzmanager",
$"Konnte Instanzmanager nicht starten: {ex.Message}");
}
}
/// <summary>
/// Sucht die Programmdatei des Instanzmanagers.
///
/// <para><b>Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette.</b> Hier
/// stand <c>@"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0"</c> — unter Linux,
/// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit
/// Rückstrichen darin, und die Suche ging dort nie auf.</para>
///
/// <para>Die Entwicklungspfade bleiben, weil im Betrieb beide Programme
/// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei
/// vergebliche <c>File.Exists</c>.</para>
/// </summary>
private static string? FindInstanceManagerExecutable()
{
var exeName = OperatingSystem.IsWindows()
? "ClawdDotNet.Instanzmanager.exe"
: "ClawdDotNet.Instanzmanager";
var nebenan = Path.Combine(AppContext.BaseDirectory, exeName);
if (File.Exists(nebenan)) return nebenan;
string[][] entwicklungspfade =
[
["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"],
["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"]
];
foreach (var segmente in entwicklungspfade)
{
var pfad = Path.GetFullPath(Path.Combine(
[AppContext.BaseDirectory, .. segmente, exeName]));
if (File.Exists(pfad)) return pfad;
}
return null;
}
/// <summary>
/// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet.
///
/// <para>Aus <c>manager.json</c> gelesen und nicht fest eingetragen: Wer den Port
/// dort ändert, bekäme sonst einen Browser auf eine Adresse, an der nichts
/// antwortet. Diese Anwendung kennt den Typ hinter der Datei nicht — sie liest das
/// eine Feld, das sie braucht, und nimmt bei jedem Zweifel die Vorgabe.</para>
/// </summary>
private static int ManagerPort()
{
const int vorgabe = 7000;
try
{
var datei = Path.Combine(AppPaths.ConfigDirectory, "manager.json");
if (!File.Exists(datei)) return vorgabe;
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(datei));
return doc.RootElement.TryGetProperty("webUiPort", out var port)
&& port.ValueKind == System.Text.Json.JsonValueKind.Number
&& port.TryGetInt32(out var wert)
&& wert is > 0 and < 65536
? wert
: vorgabe;
}
catch (Exception)
{
// Eine unlesbare Managerdatei ist kein Grund, den Browser gar nicht zu oeffnen.
return vorgabe;
}
}
private static async Task<StandaloneSwitchChoice> AskStandaloneSwitchModeAsync(Window? owner)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<StandaloneSwitchChoice>();
var l = LocalizationManager.Instance;
var managerBtn = new Button
{
Content = l["desktop.startMode.managerBtn"],
Classes = { "primary" }
};
var switchBtn = new Button
{
Content = l["desktop.standaloneSwitch.switchAnyway"]
};
var cancelBtn = new Button
{
Content = l["common.cancel"],
IsCancel = true
};
var window = new Window
{
Title = l["desktop.standaloneSwitch.title"],
Width = 540,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Icon = owner?.Icon,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = l["desktop.standaloneSwitch.warning"],
Classes = { "h1" }
},
new TextBlock
{
Text = l["desktop.standaloneSwitch.text"],
TextWrapping = TextWrapping.Wrap
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancelBtn, switchBtn, managerBtn }
}
}
}
};
managerBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.LaunchManager);
window.Close();
};
switchBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.SwitchStandalone);
window.Close();
};
cancelBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.Cancel);
window.Close();
};
window.Closed += (_, _) => completion.TrySetResult(StandaloneSwitchChoice.Cancel);
if (owner is not null)
await window.ShowDialog(owner);
else
window.Show();
return await completion.Task;
});
private static async Task<StartModeChoice> AskStartModeAsync()
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<StartModeChoice>();
var l = LocalizationManager.Instance;
var managerBtn = new Button
{
Content = l["desktop.startMode.managerBtn"],
Classes = { "primary" }
};
var standaloneBtn = new Button
{
Content = l["desktop.startMode.standaloneBtn"]
};
var cancelBtn = new Button
{
Content = l["common.cancel"],
IsCancel = true
};
var window = new Window
{
Title = l["desktop.startMode.title"],
Width = 540,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Thickness(22),
Spacing = 16,
Children =
{
new TextBlock
{
Text = l["desktop.startMode.question"],
Classes = { "h1" }
},
new TextBlock
{
Text = l["desktop.startMode.explanation"],
TextWrapping = TextWrapping.Wrap
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancelBtn, standaloneBtn, managerBtn }
}
}
}
};
managerBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.LaunchManager);
window.Close();
};
standaloneBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.Standalone);
window.Close();
};
cancelBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.Cancel);
window.Close();
};
window.Closed += (_, _) => completion.TrySetResult(StartModeChoice.Cancel);
window.Show();
return await completion.Task;
});
private int _beendetSich;
/// <summary>
/// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat.
///
/// <para><b>Erst auslaufen lassen, dann abschneiden.</b> Laufende Agenten und
/// beanspruchte Aufgaben bekommen ihre Frist
/// (<see cref="ShutdownCoordinator"/>), bevor Weboberfläche und Aufbau fallen. Ohne
/// das endete eine Aufgabe mitten in <c>ProcessAsync</c> und bliebe bis zum Ablauf
/// ihres Lease auf <c>running</c> stehen.</para>
///
/// <para><b>Und nicht im Rückruf von <c>desktop.ShutdownRequested</c>.</b> Avalonia
/// wartet dort nicht auf <c>async</c>-Behandlungen; was länger dauert als ein
/// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst
/// aufräumen und <em>dann</em> <c>Shutdown</c> rufen — der Rückruf dort bleibt als
/// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts.</para>
/// </summary>
private async Task BeendeGeordnetAsync(
IClassicDesktopStyleApplicationLifetime desktop, string grund)
{
if (Interlocked.Exchange(ref _beendetSich, 1) != 0) return;
var host = _host;
if (host is not null)
{
host.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown")
.LogInformation("Beendigung angefordert ({Grund}).", grund);
try { await host.Shutdown.BeginAsync(); }
catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
}
await ShutdownWebAsync();
if (host is not null) await host.DisposeAsync();
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
/// <summary>
/// Faehrt die Weboberflaeche herunter, falls sie laeuft, und merkt sich, dass sie
/// weg ist. Mehrfach aufrufbar: Update, Instanzwechsel und das gewoehnliche
/// Beenden nehmen denselben Weg, und der letzte von ihnen soll nicht ins Leere
/// greifen.
/// </summary>
private async Task ShutdownWebAsync()
{
if (_web is null) return;
var web = _web;
_web = null;
await web.DisposeAsync();
}
/// <summary>
/// Instanzauswahl beim Start.
/// </summary>
private async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
{
var preselected = _preselectedInstancePath;
_preselectedInstancePath = null;
if (preselected is not null && Directory.Exists(preselected))
return preselected;
// Hier steht bewusst der Schalter und nicht der geprüfte Befund: Diese Methode
// läuft mitten im Aufbau, bevor es einen AppHost gibt, der geprüft haben könnte.
// Sie entscheidet auch nur, ob ein Dialog erscheint — wer --managed ohne gültige
// Unterschrift setzt, bekommt ihn nicht und wird gleich darauf trotzdem nach
// seiner Lizenz gefragt (#535).
if (!_managed)
{
var choice = await AskStartModeAsync();
if (choice == StartModeChoice.LaunchManager)
{
LaunchInstanceManagerAndOpenBrowser();
return null;
}
if (choice == StartModeChoice.Cancel)
{
return null;
}
}
return await ShowInstancePickerAsync(directories);
}
private static async Task<string?> ShowInstancePickerAsync(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 = LocalizationManager.Instance["common.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.
@@ -1,93 +0,0 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -9,32 +9,14 @@
<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. -->
WinExe verhaelt sich dort ohnehin wie Exe. #764: Seit die Fenster entfernt sind,
heisst das unter Windows: Der Starter (ohne Schalter) blitzt nicht als Konsole auf. -->
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
</PropertyGroup>
<ItemGroup>
<Using Include="ClawdDotNet.Desktop.Services" />
</ItemGroup>
<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="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<!-- Assets enthaelt die App-Symbole und Assets\Fonts die beiden mitgelieferten
Schriften (Barlow, Barlow Condensed; SIL Open Font License, siehe OFL.txt).
Sie werden ueber avares:// referenziert, siehe Styles/Theme.axaml. -->
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<!-- #764: Assets enthaelt nur noch das Programmsymbol (ApplicationIcon). Die Schriften
und Stile gehoerten der Avalonia-Oberflaeche und sind mit ihr gegangen. -->
<!-- Muss neben der Programmdatei liegen, damit sie im Paket neben der manifest.json
landet: Von dort liest der update-agent sie bei 'install' und 'configure'. -->
@@ -42,13 +24,6 @@
<Content Include="setup.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="i18n\*.json" />
<None Update="i18n\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
@@ -56,7 +31,10 @@
warum das so sein muss, steht in ClawdDotNet.Web/WebUiHost.cs.
Die Richtung des Verweises ist Absicht: Desktop kennt Web, Web kennt Desktop
nicht. Wer hier umdreht, hat den Schnitt verletzt. -->
nicht. Wer hier umdreht, hat den Schnitt verletzt.
#764: Seit die Fenster entfernt sind, ist der Instanzprozess (HeadlessRun) der
einzige Wirt; die HTTP-Flaeche ist sein Zugang fuer Manager und Weboberflaeche. -->
<ProjectReference Include="..\ClawdDotNet.Web\ClawdDotNet.Web.csproj" />
</ItemGroup>
@@ -1,24 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace ClawdDotNet.Desktop.Controls;
/// <summary>
/// Der Rahmen mit den vier Eckmarken — das wiederkehrende Motiv des Entwurfs.
///
/// <para>Eigenes Steuerelement statt vier von Hand gesetzter Striche je Stelle: Die Marken
/// liegen bewusst <em>halb außerhalb</em> des Rahmens, und das ist genau die Sorte Detail,
/// die beim Nachbauen an der fünften Stelle nicht mehr stimmt.</para>
///
/// <para>Weil die Marken über den Rand hinausragen, darf kein Elternteil zuschneiden.
/// In der Praxis heißt das: nicht direkt an den Fensterrand setzen, sondern mit etwas
/// Abstand — die Vorlage im Entwurf tut dasselbe.</para>
///
/// <para>Sparsam verwenden. Im Entwurf tragen nur die Info-Karte und die beiden Dialoge
/// die Marken; alle übrigen Karten sind schlichte Haarlinien-Kästen (Klasse
/// <c>card</c> in <c>Styles/Shell.axaml</c>).</para>
/// </summary>
public sealed class BlueprintFrame : ContentControl
{
protected override Type StyleKeyOverride => typeof(BlueprintFrame);
}
@@ -1,113 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Controls;
/// <summary>
/// Ein Strichsymbol aus dem Entwurf — 24×24-Geometrie, gezeichnet mit der geerbten
/// Vordergrundfarbe.
///
/// <para>Warum nicht <c>PathIcon</c>: Das füllt seine Geometrie. Die Symbole des Entwurfs
/// sind Umrisse (Lucide, Strichstärke 1.5); gefüllt werden daraus schwarze Klumpen.</para>
///
/// <para>Warum nicht <c>Path</c> mit <c>Stroke</c>: Dann müsste an jeder einzelnen Stelle
/// die Farbe mitgegeben werden — und zwar die <em>richtige</em>, je nachdem ob das Symbol
/// in einer Schaltfläche, einer ausgewählten Navigationszeile oder der Statusleiste sitzt.
/// <see cref="Foreground"/> ist hier von <see cref="TextElement"/> übernommen und erbt
/// daher wie eine Textfarbe: Wer den Text einfärbt, färbt das Symbol mit.</para>
///
/// <para>Die Strichstärke wird beim Skalieren mitskaliert. Das ist keine Nachlässigkeit,
/// sondern das Verhalten von SVG: <c>stroke-width</c> zählt in Einheiten des viewBox.
/// Ein Symbol mit 1.5 in 24×24, dargestellt auf 16 px, hat genau 1 px starke Linien.</para>
/// </summary>
public sealed class StrokeIcon : Control
{
/// <summary>Kantenlänge des Koordinatenraums aller Symbole (SVG-viewBox 0 0 24 24).</summary>
private const double ViewBox = 24d;
public static readonly StyledProperty<Geometry?> DataProperty =
AvaloniaProperty.Register<StrokeIcon, Geometry?>(nameof(Data));
/// <summary>Geerbt wie eine Textfarbe — siehe Klassenbeschreibung.</summary>
public static readonly StyledProperty<IBrush?> ForegroundProperty =
TextElement.ForegroundProperty.AddOwner<StrokeIcon>();
public static readonly StyledProperty<double> StrokeThicknessProperty =
AvaloniaProperty.Register<StrokeIcon, double>(nameof(StrokeThickness), 1.5d);
/// <summary>Für die wenigen massiven Symbole des Entwurfs (Wiedergabe, Halt).</summary>
public static readonly StyledProperty<bool> IsFilledProperty =
AvaloniaProperty.Register<StrokeIcon, bool>(nameof(IsFilled));
static StrokeIcon()
{
AffectsRender<StrokeIcon>(
DataProperty, ForegroundProperty, StrokeThicknessProperty, IsFilledProperty);
AffectsMeasure<StrokeIcon>(DataProperty);
}
public Geometry? Data
{
get => GetValue(DataProperty);
set => SetValue(DataProperty, value);
}
public IBrush? Foreground
{
get => GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public double StrokeThickness
{
get => GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public bool IsFilled
{
get => GetValue(IsFilledProperty);
set => SetValue(IsFilledProperty, value);
}
/// <summary>
/// Ohne gesetzte Größe 16×16 — die Größe, in der die meisten Symbole im Entwurf stehen.
/// Ein unbegrenzter Vorschlag würde das Symbol den ganzen Platz nehmen lassen.
/// </summary>
protected override Size MeasureOverride(Size availableSize)
{
var width = double.IsInfinity(availableSize.Width) ? 16d : Math.Min(16d, availableSize.Width);
var height = double.IsInfinity(availableSize.Height) ? 16d : Math.Min(16d, availableSize.Height);
return new Size(width, height);
}
public override void Render(DrawingContext context)
{
if (Data is not { } geometry || Foreground is not { } brush) return;
var scale = Math.Min(Bounds.Width / ViewBox, Bounds.Height / ViewBox);
if (scale <= 0) return;
// Mittig setzen, falls das Steuerelement nicht quadratisch ist.
var offsetX = (Bounds.Width - ViewBox * scale) / 2;
var offsetY = (Bounds.Height - ViewBox * scale) / 2;
using var _ = context.PushTransform(
Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(offsetX, offsetY));
if (IsFilled)
{
context.DrawGeometry(brush, null, geometry);
return;
}
// Runde Enden und Verbindungen: Ohne sie verschwinden die Punkt-Symbole
// (Pfadstücke der Länge 0.01, etwa in „Agenten-Chats") vollständig.
var pen = new Pen(brush, StrokeThickness,
lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, pen, geometry);
}
}
@@ -1,54 +0,0 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Schlägt eine Symbol-Geometrie aus <c>Styles/Icons.axaml</c> über ihren Namen nach.
///
/// <para>Damit halten die Ansichtsmodelle nur den Namen („IconChat") statt einer
/// <see cref="Geometry"/> — sie brauchen dann keinen Verweis auf <c>Avalonia.Media</c>,
/// und die Navigationsleiste kommt mit einer schlichten Zeichenkette je Eintrag aus.</para>
///
/// <para>Dass hier keine Themenumschaltung nötig ist, ist kein Zufall: Die Geometrien sind
/// themenunabhängig, gefärbt wird erst beim Zeichnen über die geerbte Vordergrundfarbe
/// (siehe <c>Controls/StrokeIcon.cs</c>). Für Farben wäre dieser Weg falsch — ein einmal
/// aufgelöster Pinsel bliebe beim Wechsel auf Dunkel stehen.</para>
/// </summary>
public sealed class IconConverter : IValueConverter
{
public static readonly IconConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrEmpty(key)) return null;
if (Application.Current is not { } app) return null;
return app.TryFindResource(key, out var resource) ? resource as Geometry : null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Der Pfeil am Einklapp-Schalter der Seitenleiste: zeigt nach links, solange
/// eingeklappt werden kann, und nach rechts, wenn wieder aufgeklappt wird.
/// </summary>
public sealed class SidebarChevronConverter : IValueConverter
{
public static readonly SidebarChevronConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var isCollapsed = value is true;
return IconConverter.Instance.Convert(
isCollapsed ? "IconChevronRight" : "IconChevronLeft", targetType, parameter, culture);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -1,40 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Layout;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Richtet Sprechblasen aus: rechts fuer den Benutzer, links fuer den Agenten.
///
/// <para>Ausrichtung und nicht Farbe — deshalb ist ein Konverter hier richtig. Der
/// frueher danebenstehende ChatBubbleBrushConverter lieferte einen Pinsel mit festem
/// Farbwert und ueberlebte den Themenwechsel nicht; die Hinterlegung eigener Nachrichten
/// laeuft jetzt ueber die Stilklasse <c>card accent</c>.</para>
/// </summary>
public sealed class ChatAlignmentConverter : IValueConverter
{
public static readonly ChatAlignmentConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? HorizontalAlignment.Right : HorizontalAlignment.Left;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
/// <summary>
/// Prueft, ob ein Werkzeugname mit dem <c>ConverterParameter</c> uebereinstimmt.
/// Blendet im Werkzeug-Einstellungsbereich die passende Feldgruppe ein.
/// </summary>
public sealed class ToolMatchConverter : IValueConverter
{
public static readonly ToolMatchConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string toolName && parameter is string targetTool
&& toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
@@ -1,27 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClawdDotNet.Desktop.Converters;
/// <summary>
/// Vergleicht eine Zeichenkette mit dem <c>ConverterParameter</c>, ohne Gross- und
/// Kleinschreibung zu unterscheiden.
///
/// <para>Gedacht fuer <c>Classes.xyz="{Binding …}"</c>: Die Ansicht setzt damit eine
/// Stilklasse, und die Farbe dazu steht in <c>Styles/Shell.axaml</c> an
/// <c>DynamicResource</c>. Das ersetzt den frueheren LogLevelBrushConverter, der
/// Pinsel mit festen Farbwerten zurueckgab — die blieben beim Wechsel auf Dunkel
/// unveraendert stehen, weil ein einmal gelieferter Pinsel nichts mehr vom Thema
/// mitbekommt.</para>
/// </summary>
public sealed class StringEqualsConverter : IValueConverter
{
public static readonly StringEqualsConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string text && parameter is string expected
&& text.Equals(expected, StringComparison.OrdinalIgnoreCase);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("Nur zur Anzeige.");
}
+180
View File
@@ -0,0 +1,180 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Desktop;
/// <summary>
/// #764: <c>ClawdDotNet</c> started without a switch — by a double click, a start menu entry,
/// a desktop file.
///
/// <para>Until #764 that opened the Avalonia window. The windows are gone; the web interface is
/// the only one. What a person who starts the program wants is therefore: the instance manager
/// running, and its web interface in front of them. Until the shell with its own window exists
/// (#764, stage 2) the system browser shows it.</para>
///
/// <para><b>Never a second manager.</b> If the manager answers, it is left alone. If it runs as
/// a systemd service on this machine, the service is started — starting the program next to it
/// would put two managers on the same instances. Only otherwise is the manager program started
/// directly.</para>
/// </summary>
internal static class LauncherRun
{
private const int ExitOk = 0;
private const int ExitNoManager = 6;
private const int DefaultPort = 7000;
private const string ServiceUnit = "clawddotnet-manager.service";
private static readonly TimeSpan StartWait = TimeSpan.FromSeconds(30);
public static async Task<int> RunAsync()
{
var port = ManagerPort();
if (!Answers(port))
{
var problem = StartManager();
if (problem is not null)
{
await Console.Error.WriteLineAsync(problem);
return ExitNoManager;
}
var until = DateTime.UtcNow + StartWait;
while (!Answers(port) && DateTime.UtcNow < until)
await Task.Delay(500);
if (!Answers(port))
{
await Console.Error.WriteLineAsync(
$"The instance manager did not answer on port {port} within {StartWait.TotalSeconds:0} s. "
+ "Its log is under Logs/Manager in the data directory.");
return ExitNoManager;
}
}
var url = $"http://localhost:{port}/";
Console.WriteLine(url);
SystemShell.OpenUrl(url);
return ExitOk;
}
/// <returns>Why the manager could not be started, or <c>null</c>.</returns>
private static string? StartManager()
{
if (OperatingSystem.IsLinux() && Systemctl("cat", ServiceUnit) == 0)
{
return Systemctl("start", ServiceUnit) == 0
? null
: $"systemctl --user start {ServiceUnit} failed; see journalctl --user -u {ServiceUnit}.";
}
if (FindManagerExecutable() is not { } manager)
{
return "The instance manager (ClawdDotNet.Instanzmanager) was not found next to this program.";
}
try
{
// On Windows with its own console window: that is where the manager shows the access
// token on first start. Elsewhere a plain child that outlives us.
Process.Start(new ProcessStartInfo(manager)
{
UseShellExecute = OperatingSystem.IsWindows(),
WorkingDirectory = Path.GetDirectoryName(manager)!
});
return null;
}
catch (Exception ex)
{
return $"The instance manager could not be started: {ex.Message}";
}
}
private static bool Answers(int port)
{
try
{
using var client = new TcpClient();
return client.ConnectAsync("127.0.0.1", port).Wait(TimeSpan.FromSeconds(1)) && client.Connected;
}
catch (Exception)
{
return false;
}
}
private static int Systemctl(string verb, string unit)
{
try
{
var start = new ProcessStartInfo("systemctl") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add("--user");
start.ArgumentList.Add(verb);
start.ArgumentList.Add(unit);
using var p = Process.Start(start)!;
p.StandardOutput.ReadToEnd();
p.StandardError.ReadToEnd();
p.WaitForExit(30_000);
return p.HasExited ? p.ExitCode : -1;
}
catch (Exception)
{
return -1; // no systemd here
}
}
/// <summary>
/// Next to this program in an installation; the build folders in a development checkout.
///
/// <para>Built from segments, not as a string: a path with backslashes is one file name on
/// Linux, and the search never found anything there (moved here from <c>App.axaml.cs</c>).</para>
/// </summary>
private static string? FindManagerExecutable()
{
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.Instanzmanager.exe" : "ClawdDotNet.Instanzmanager";
var nextTo = Path.Combine(AppContext.BaseDirectory, name);
if (File.Exists(nextTo)) return nextTo;
string[][] developmentPaths =
[
["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"],
["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"]
];
foreach (var segments in developmentPaths)
{
var path = Path.GetFullPath(Path.Combine([AppContext.BaseDirectory, .. segments, name]));
if (File.Exists(path)) return path;
}
return null;
}
/// <summary>
/// The port from <c>manager.json</c>; the default on any doubt. Read as one field, not as
/// the manager's settings type, which this program does not know.
/// </summary>
private static int ManagerPort()
{
try
{
var file = Path.Combine(AppPaths.ConfigDirectory, "manager.json");
if (!File.Exists(file)) return DefaultPort;
using var doc = JsonDocument.Parse(File.ReadAllText(file));
return doc.RootElement.TryGetProperty("webUiPort", out var port)
&& port.ValueKind == JsonValueKind.Number
&& port.TryGetInt32(out var value)
&& value is > 0 and < 65536
? value
: DefaultPort;
}
catch (Exception)
{
return DefaultPort;
}
}
}
+11 -29
View File
@@ -1,25 +1,21 @@
using Avalonia;
namespace ClawdDotNet.Desktop;
internal static class Program
{
/// <summary>
/// Einstiegspunkt der Oberfläche.
/// Einstiegspunkt.
///
/// 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.
/// <para>#764: Bis hierher fuhr ohne Schalter die Avalonia-Oberfläche hoch. Die Fenster
/// sind entfernt — die Weboberfläche ist die einzige Oberfläche. Die Programmdatei bleibt,
/// was sie für den Betrieb immer war: der Instanzprozess (<c>--headless --managed
/// --instance …</c>, so startet der Manager jede Instanz), die MCP-Brücke und der Starter
/// in der Sandbox. Ohne Schalter startet sie den Instanzmanager, falls er nicht läuft, und
/// öffnet die Weboberfläche (<see cref="LauncherRun"/>).</para>
///
/// 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.
/// <para>Kein Fenstersystem wird angefasst — auf einem Server ohne Bildschirmsitzung gäbe
/// es keines (#528, Stufe 2). Das galt bisher für <c>--headless</c> und gilt jetzt für
/// alles.</para>
/// </summary>
/// <remarks>
/// Die Verzweigung steht vor <see cref="BuildAvaloniaApp"/> und nicht darin: Mit
/// <c>--headless</c> soll gar kein Fenstersystem angefasst werden — auf einem Server
/// ohne Bildschirmsitzung gäbe es keines (#528, Stufe 2).
/// </remarks>
public static int Main(string[] args)
=> CommandLine.IsSandboxLaunch(args)
? Core.Sandbox.SandboxLauncher.RunAsync(args).GetAwaiter().GetResult()
@@ -27,19 +23,5 @@ internal static class Program
? McpBridgeRun.RunAsync(args).GetAwaiter().GetResult()
: CommandLine.IsHeadless(args)
? HeadlessRun.RunAsync(args).GetAwaiter().GetResult()
: 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()
// Kein WithInterFont mehr: Die Oberfläche bringt seit dem Umbau auf den
// Entwurf Barlow und Barlow Condensed als eigene Ressourcen mit
// (Assets/Fonts, referenziert in Styles/Theme.axaml). Der Grund von damals
// gilt unverändert — ein schlankes Linux-Abbild hat oft gar keine
// Systemschrift —, nur liegt die mitgelieferte Schrift jetzt woanders.
.LogToTrace();
: LauncherRun.RunAsync().GetAwaiter().GetResult();
}
@@ -1,86 +0,0 @@
using Avalonia.Controls;
using Avalonia.Threading;
using ClawdDotNet.App.Services;
using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
using ClawdDotNet.Licensing;
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;
});
}
@@ -1,199 +0,0 @@
using System.ComponentModel;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace ClawdDotNet.Desktop.Services;
public sealed record LanguageOption(string Code, string Name)
{
public override string ToString() => Name;
}
/// <summary>
/// Manages application localization for Avalonia UI.
/// Supports JSON language packs with fallback to English, dynamic language switching,
/// and live UI re-binding via indexer property notification.
/// </summary>
public sealed class LocalizationManager : INotifyPropertyChanged
{
public static LocalizationManager Instance { get; } = new();
public const string DefaultLanguage = "en";
public const string FallbackLanguage = "en";
private readonly Dictionary<string, string> _fallbackStrings = new(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, string> _activeStrings = new(StringComparer.OrdinalIgnoreCase);
private string _currentLanguage = DefaultLanguage;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
[
new("en", "English"),
new("de", "Deutsch")
];
public event PropertyChangedEventHandler? PropertyChanged;
public event Action? LanguageChanged;
public string CurrentLanguage
{
get => _currentLanguage;
private set
{
if (_currentLanguage != value)
{
_currentLanguage = value;
OnPropertyChanged();
}
}
}
[IndexerName("Item")]
public string this[string key] => Get(key);
private LocalizationManager()
{
LoadFallbackPack();
SetLanguage(DefaultLanguage);
}
private void LoadFallbackPack()
{
_fallbackStrings.Clear();
var json = LoadLanguageJson(FallbackLanguage);
if (!string.IsNullOrWhiteSpace(json))
{
ParseJsonIntoDictionary(json, _fallbackStrings);
}
}
public void SetLanguage(string? languageCode)
{
var target = string.IsNullOrWhiteSpace(languageCode) ? DefaultLanguage : languageCode.Trim().ToLowerInvariant();
if (target == FallbackLanguage)
{
_activeStrings = _fallbackStrings;
}
else
{
var pack = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var json = LoadLanguageJson(target);
if (!string.IsNullOrWhiteSpace(json))
{
ParseJsonIntoDictionary(json, pack);
}
_activeStrings = pack;
}
CurrentLanguage = target;
// Notify indexer bindings so Avalonia refreshes all localized strings on screen
OnPropertyChanged("Item");
OnPropertyChanged("Item[]");
LanguageChanged?.Invoke();
}
public string Get(string key, params object[] args)
{
if (string.IsNullOrEmpty(key)) return "";
string? template = null;
if (!_activeStrings.TryGetValue(key, out template))
{
_fallbackStrings.TryGetValue(key, out template);
}
template ??= key;
if (args.Length > 0)
{
try
{
return string.Format(template, args);
}
catch
{
return template;
}
}
return template;
}
private static string? LoadLanguageJson(string langCode)
{
// 1. Try file on disk in AppContext.BaseDirectory/i18n/{langCode}.json
var localPath = Path.Combine(AppContext.BaseDirectory, "i18n", $"{langCode}.json");
if (File.Exists(localPath))
{
try
{
return File.ReadAllText(localPath);
}
catch
{
// Fall through to embedded resource
}
}
// 2. Try embedded resource in ClawdDotNet assembly
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(r => r.EndsWith($"i18n.{langCode}.json", StringComparison.OrdinalIgnoreCase));
if (resourceName is not null)
{
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is not null)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
return null;
}
private static void ParseJsonIntoDictionary(string json, Dictionary<string, string> dict)
{
try
{
using var doc = JsonDocument.Parse(json);
FlattenElement("", doc.RootElement, dict);
}
catch
{
// Invalid JSON ignored
}
}
private static void FlattenElement(string prefix, JsonElement element, Dictionary<string, string> dict)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in element.EnumerateObject())
{
var childPrefix = string.IsNullOrEmpty(prefix) ? prop.Name : $"{prefix}.{prop.Name}";
FlattenElement(childPrefix, prop.Value, dict);
}
break;
case JsonValueKind.String:
dict[prefix] = element.GetString() ?? "";
break;
case JsonValueKind.Number:
case JsonValueKind.True:
case JsonValueKind.False:
dict[prefix] = element.ToString();
break;
}
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
@@ -1,30 +0,0 @@
using Avalonia.Data;
using Avalonia.Markup.Xaml;
namespace ClawdDotNet.Desktop.Services;
/// <summary>
/// Markup extension for Avalonia XAML:
/// Usage: Text="{services:Localize nav.chat}"
/// Updates automatically when language changes.
/// </summary>
public sealed class LocalizeExtension : MarkupExtension
{
public string Key { get; set; } = "";
public LocalizeExtension() { }
public LocalizeExtension(string key)
{
Key = key;
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
return new Binding($"[{Key}]")
{
Source = LocalizationManager.Instance,
Mode = BindingMode.OneWay
};
}
}
@@ -1,35 +0,0 @@
using Avalonia;
using Avalonia.Styling;
namespace ClawdDotNet.Desktop.Services;
/// <summary>
/// Hell/Dunkel umschalten — an genau einer Stelle.
///
/// <para>Die Farbwerte selbst stehen in <c>Styles/Theme.axaml</c> als
/// <c>ThemeDictionaries</c>. Hier wird nur umgelegt, welches der beiden Wörterbücher gilt;
/// jede Bindung mit <c>DynamicResource</c> zieht dann von allein nach. Deshalb gibt es
/// hier auch keine Liste von Steuerelementen, die nachgefärbt werden müssten — die wäre
/// beim nächsten neuen Bereich schon wieder unvollständig.</para>
/// </summary>
public static class ThemeManager
{
/// <summary>Name in den Einstellungen, wenn dunkel gewählt ist.</summary>
public const string DarkName = "Dark";
/// <summary>Übersetzt den gespeicherten Namen; alles außer „Dark" gilt als hell.</summary>
public static bool IsDarkName(string? name)
=> string.Equals(name, DarkName, StringComparison.OrdinalIgnoreCase);
public static string ToName(bool isDark) => isDark ? DarkName : "Light";
/// <summary>
/// Legt die Variante an der Anwendung um. Ohne laufende Anwendung — etwa im
/// Entwurfsmodus des Editors — passiert nichts.
/// </summary>
public static void Apply(bool isDark)
{
if (Application.Current is not { } app) return;
app.RequestedThemeVariant = isDark ? ThemeVariant.Dark : ThemeVariant.Light;
}
}
@@ -1,51 +0,0 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Die Symbole des Entwurfs, 1:1 aus dem Mockup übernommen (Lucide-Umrisse, viewBox
0 0 24 24, Strichstärke 1.5). Gezeichnet werden sie von Controls/StrokeIcon.cs, das
denselben Koordinatenraum voraussetzt — wer hier ein Symbol ergänzt, muss es also
ebenfalls auf 24×24 bringen.
Kreise stehen als zwei Halbbögen da (a-Befehle): StreamGeometry kennt keine
<Ellipse>, und ein Rechteck mit vollem Radius wäre nicht dasselbe.
-->
<!-- ─── Marke ─── -->
<StreamGeometry x:Key="IconLogo">M12 9c-1.5-2.5-4-3-5.5-1.5S5 11 7 12c-1 1-1.5 2-1.5 3.5S7 18 8.5 17c1-1 1.5-2 1.5-3 M12 9c1.5-2.5 4-3 5.5-1.5S19 11 17 12c1 1 1.5 2 1.5 3.5S15 18 13.5 17c-1-1-1.5-2-1.5-3 M12 9v3</StreamGeometry>
<!-- ─── Navigation ─── -->
<StreamGeometry x:Key="IconChat">M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z</StreamGeometry>
<StreamGeometry x:Key="IconAgents">M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75</StreamGeometry>
<StreamGeometry x:Key="IconTasks">M3 17l2 2 4-4 M3 7l2 2 4-4 M13 6h8 M13 12h8 M13 18h8</StreamGeometry>
<StreamGeometry x:Key="IconChart">M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7</StreamGeometry>
<StreamGeometry x:Key="IconAgentChats">M8 12h.01 M12 12h.01 M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z</StreamGeometry>
<StreamGeometry x:Key="IconBackup">M3 4h18a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9 M10 13h4</StreamGeometry>
<StreamGeometry x:Key="IconLogs">M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M9 13h6 M9 17h6</StreamGeometry>
<StreamGeometry x:Key="IconSettings">M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9c.13-.32.19-.66.18-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.5 1.24.66 1.82.33H9c.32-.13.66-.19 1-.18A1.65 1.65 0 0 0 11.51 3H12a2 2 0 0 1 4 0v.09c0 .34.06.68.18 1a1.65 1.65 0 0 0 1.82.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06c-.51.5-.67 1.24-.33 1.82V9c.13.32.19.66.18 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z</StreamGeometry>
<StreamGeometry x:Key="IconInfo">M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0 M12 16v-4 M12 8h.01</StreamGeometry>
<!-- ─── Kopfzeile ─── -->
<StreamGeometry x:Key="IconSun">M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M12 2v2 M12 20v2 M4.9 4.9l1.4 1.4 M17.7 17.7l1.4 1.4 M2 12h2 M20 12h2 M4.9 19.1l1.4-1.4 M17.7 6.3l1.4-1.4</StreamGeometry>
<StreamGeometry x:Key="IconMoon">M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z</StreamGeometry>
<StreamGeometry x:Key="IconChevronLeft">M15 18l-6-6 6-6</StreamGeometry>
<StreamGeometry x:Key="IconChevronRight">M9 18l6-6-6-6</StreamGeometry>
<StreamGeometry x:Key="IconChevronDown">M6 9l6 6 6-6</StreamGeometry>
<!-- ─── Meldungen ─── -->
<StreamGeometry x:Key="IconAlert">M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z</StreamGeometry>
<StreamGeometry x:Key="IconClose">M18 6L6 18 M6 6l12 12</StreamGeometry>
<!-- ─── Werkzeugleisten ─── -->
<StreamGeometry x:Key="IconPlus">M12 5v14 M5 12h14</StreamGeometry>
<StreamGeometry x:Key="IconMinus">M5 12h14</StreamGeometry>
<StreamGeometry x:Key="IconPlay">M8 5v14l11-7z</StreamGeometry>
<StreamGeometry x:Key="IconStop">M6 6h12v12H6z</StreamGeometry>
<StreamGeometry x:Key="IconPencil">M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z</StreamGeometry>
<StreamGeometry x:Key="IconTrash">M3 6h18 M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6</StreamGeometry>
<StreamGeometry x:Key="IconFolder">M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2Z</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M20.49 15a9 9 0 0 1-14.85 3.36L1 14</StreamGeometry>
<StreamGeometry x:Key="IconPower">M18.36 6.64a9 9 0 1 1-12.73 0 M12 2v10</StreamGeometry>
<StreamGeometry x:Key="IconSave">M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8</StreamGeometry>
</ResourceDictionary>
-862
View File
@@ -1,862 +0,0 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls">
<!--
Die gemeinsame Anmutung — Umsetzung des Entwurfs aus Mockup/.
Warum eigene ControlTheme statt Fluent umzufärben: Avalonia 12 hat die Ressourcen des
Fluent-Themas umgebaut. Die aus 11er-Anleitungen bekannten Schlüssel (ButtonBackground,
TextControlBackground, ControlCornerRadius …) existieren nicht mehr; ein Setter darauf
ist wirkungslos und fällt nicht auf, weil er weder Build noch Test bricht. Für die
prägenden Steuerelemente steht die Vorlage deshalb hier vollständig. Fluent bleibt
darunter als Grundlage für alles Übrige (Bildlaufleisten, Menüs, Popup-Rahmen).
Regel wie gehabt: keine festen Farbwerte. Alles über DynamicResource aus Theme.axaml,
sonst überlebt es den Wechsel zwischen hell und dunkel nicht.
-->
<Styles.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ClawdDotNet/Styles/Theme.axaml" />
<ResourceInclude Source="avares://ClawdDotNet/Styles/Icons.axaml" />
</ResourceDictionary.MergedDictionaries>
<!-- ════════════════════════════ Schaltflächen ════════════════════════════
Grundform ist die sekundäre Schaltfläche des Entwurfs: durchsichtig, eine
Haarlinie Rahmen, eckig. Die Spielarten kommen als Klasse dazu:
.primary / .accent gefüllt in Akzentfarbe
.toolbar kleiner, Fließtextschrift — für Werkzeugleisten
.ghost ohne Rahmen, Akzentfarbe
.flat ohne Rahmen, Textfarbe (Schließen, Einklappen)
.icon quadratisch, ohne Innenabstand
.accent bleibt neben .primary bestehen: Die noch nicht überarbeiteten Dialoge
verwenden diesen Namen. -->
<ControlTheme x:Key="{x:Type Button}" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
RecognizesAccessKey="True" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource PressedBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.45" />
<Setter Property="Cursor" Value="Arrow" />
</Style>
<Style Selector="^.primary, ^.accent">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
</Style>
<Style Selector="^.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter,
^.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent600Brush}" />
</Style>
<Style Selector="^.primary:pressed /template/ ContentPresenter#PART_ContentPresenter,
^.accent:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource Accent700Brush}" />
</Style>
<Style Selector="^.toolbar">
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Padding" Value="10,6" />
</Style>
<Style Selector="^.ghost">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="^.flat">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="^.icon">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
</Style>
</ControlTheme>
<!-- Umschalt-Schaltfläche: dieselbe Form, plus ausgewählter Zustand.
Trägt im Entwurf die Segmentleiste über den Aufgaben-Listen. -->
<ControlTheme x:Key="{x:Type ToggleButton}" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="14,7" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:checked">
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
</Style>
<Style Selector="^:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Eingabefelder ════════════════════════════ -->
<ControlTheme x:Key="{x:Type TextBox}" TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="CaretBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
<Setter Property="SelectionForegroundBrush" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="9,5" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Panel>
<Border Name="PART_BorderElement"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
MinHeight="{TemplateBinding MinHeight}" />
<DockPanel Margin="{TemplateBinding Padding}">
<ContentPresenter Content="{TemplateBinding InnerLeftContent}" DockPanel.Dock="Left" />
<ContentPresenter Content="{TemplateBinding InnerRightContent}" DockPanel.Dock="Right" />
<ScrollViewer Name="PART_ScrollViewer"
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
<Panel>
<!-- Der Platzhalter verschwindet, sobald etwas im Feld steht. Über
IsVisible statt Opacity, damit er keine Maus-Ereignisse abfängt. -->
<TextBlock Name="PART_Watermark"
Text="{TemplateBinding PlaceholderText}"
Foreground="{DynamicResource TextGhostBrush}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
IsVisible="{TemplateBinding Text, Converter={x:Static StringConverters.IsNullOrEmpty}}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
<TextPresenter Name="PART_TextPresenter"
Text="{TemplateBinding Text, Mode=TwoWay}"
CaretIndex="{TemplateBinding CaretIndex}"
SelectionStart="{TemplateBinding SelectionStart}"
SelectionEnd="{TemplateBinding SelectionEnd}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
PasswordChar="{TemplateBinding PasswordChar}"
RevealPassword="{TemplateBinding RevealPassword}"
SelectionBrush="{TemplateBinding SelectionBrush}"
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
CaretBrush="{TemplateBinding CaretBrush}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Panel>
</ScrollViewer>
</DockPanel>
</Panel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
</Style>
<Style Selector="^:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
<!-- Mehrzeilig: Text oben beginnen lassen, nicht mittig. -->
<Style Selector="^[AcceptsReturn=True]">
<Setter Property="VerticalContentAlignment" Value="Top" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Auswahlfeld ════════════════════════════ -->
<ControlTheme x:Key="{x:Type ComboBox}" TargetType="ComboBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Padding" Value="9,5" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="PlaceholderForeground" Value="{DynamicResource TextGhostBrush}" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<Border Name="Background" Grid.ColumnSpan="2"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
MinHeight="{TemplateBinding MinHeight}" />
<TextBlock Name="PlaceholderTextBlock" Grid.Column="0"
Text="{TemplateBinding PlaceholderText}"
Foreground="{TemplateBinding PlaceholderForeground}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
IsVisible="{TemplateBinding SelectionBoxItem, Converter={x:Static ObjectConverters.IsNull}}" />
<ContentControl Name="ContentPresenter" Grid.Column="0"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding ItemTemplate}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center" />
<ctrl:StrokeIcon Grid.Column="1" Data="{StaticResource IconChevronDown}"
Width="12" Height="12" Margin="0,0,9,0"
VerticalAlignment="Center" IsHitTestVisible="False" />
<Popup Name="PART_Popup"
IsOpen="{TemplateBinding IsDropDownOpen, Mode=TwoWay}"
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
PlacementTarget="Background"
IsLightDismissEnabled="True">
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="1" CornerRadius="0" Padding="0,2">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="320">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Background">
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
</Style>
<Style Selector="^:focus /template/ Border#Background">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type ComboBoxItem}" TargetType="ComboBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
</Style>
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
</Style>
</ControlTheme>
<!-- ════════════════════ Kontrollkästchen & Auswahlknopf ════════════════════
Beide teilen sich die Form aus dem Entwurf: 16 px Marke, 1.5 px Rand, im
gewählten Zustand mit Akzent gefüllt. Unterschied ist allein die Rundung —
eckig für Mehrfach-, rund für Einfachauswahl. -->
<ControlTheme x:Key="{x:Type CheckBox}" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
<Border Name="Mark" Width="16" Height="16" CornerRadius="3"
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
Background="Transparent" VerticalAlignment="Center">
<ctrl:StrokeIcon Name="Glyph" Width="12" Height="12" StrokeThickness="3"
Foreground="{DynamicResource BgBrush}"
Data="M4 12l5 5L20 6" IsVisible="False" />
</Border>
<ContentPresenter Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
VerticalAlignment="Center" />
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Mark">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ ctrl|StrokeIcon#Glyph">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:indeterminate /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type RadioButton}" TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
<Border Name="Mark" Width="16" Height="16" CornerRadius="8"
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
Background="Transparent" VerticalAlignment="Center">
<!-- Der innere Punkt entsteht wie im Entwurf durch einen Ring in
Hintergrundfarbe, nicht durch eine zweite Ellipse. -->
<Border Name="Dot" CornerRadius="8" Margin="3"
Background="{DynamicResource BgBrush}" IsVisible="False" />
</Border>
<ContentPresenter Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
VerticalAlignment="Center" />
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#Mark">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Mark">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Style>
<Style Selector="^:checked /template/ Border#Dot">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Listen ════════════════════════════ -->
<ControlTheme x:Key="{x:Type ListBox}" TargetType="ListBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ScrollViewer HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
<ItemsPresenter Name="PART_ItemsPresenter"
ItemsPanel="{TemplateBinding ItemsPanel}"
Margin="{TemplateBinding Padding}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="{x:Type ListBoxItem}" TargetType="ListBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="Padding" Value="7" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource RowHoverBrush}" />
</Style>
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentRowBrush}" />
</Style>
<Style Selector="^:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
</Style>
</ControlTheme>
<!-- ════════════════════════════ Rahmen mit Eckmarken ════════════════════════════
Die vier Marken liegen halb außerhalb des Rahmens; sie sitzen deshalb in einem
Grid über dem Rahmen und nicht in ihm. IsHitTestVisible=False, damit sie
Klicks auf den Inhalt nicht abfangen. -->
<ControlTheme x:Key="{x:Type ctrl:BlueprintFrame}" TargetType="ctrl:BlueprintFrame">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate>
<Grid>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
<Panel Width="11" Height="11" Margin="-6,-6,0,0" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Top">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="0,-6,-6,0" IsHitTestVisible="False"
HorizontalAlignment="Right" VerticalAlignment="Top">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="-6,0,0,-6" IsHitTestVisible="False"
HorizontalAlignment="Left" VerticalAlignment="Bottom">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
<Panel Width="11" Height="11" Margin="0,0,-6,-6" IsHitTestVisible="False"
HorizontalAlignment="Right" VerticalAlignment="Bottom">
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
Fill="{DynamicResource TextFaintBrush}" />
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
Fill="{DynamicResource TextFaintBrush}" />
</Panel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
</Styles.Resources>
<!-- ══════════════════════════════════════════════════════════════════════════
Grundlagen: Fenster, Schrift, Trenner
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource BgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style Selector="UserControl">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
</Style>
<!-- Überschriften laufen in Barlow Condensed, Fließtext in Barlow — das ist der
auffälligste Zug des Entwurfs und darf nirgends per Hand gesetzt werden. -->
<Style Selector="TextBlock.h1">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="TextBlock.h2">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="17" />
</Style>
<!-- Bestandsname aus der ersten Portierung; die noch nicht überarbeiteten Dialoge
binden darauf. -->
<Style Selector="TextBlock.heading">
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="17" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<!-- Die kleine Versalzeile über jedem Abschnitt — im Entwurf durchgehend in
Akzentfarbe, 10 px, weit gesperrt. -->
<Style Selector="TextBlock.kicker">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.caption">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
</Style>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<Style Selector="SelectableTextBlock">
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<!-- Log-Stufen. Über Klassen statt über einen Pinsel aus einem Konverter, damit die
Farben beim Wechsel auf Dunkel mitziehen. -->
<Style Selector="SelectableTextBlock.warn">
<Setter Property="Foreground" Value="{DynamicResource WarningBrush}" />
</Style>
<Style Selector="SelectableTextBlock.err, SelectableTextBlock.ftl">
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Flächen
══════════════════════════════════════════════════════════════════════ -->
<!-- Karte: eckig, Haarlinie, durchsichtig — der Entwurf setzt bewusst keine
Füllfarbe, damit die Kästen als Zeichnung und nicht als Kacheln wirken. -->
<Style Selector="Border.card">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="10" />
<Setter Property="Background" Value="Transparent" />
</Style>
<!-- Karte in Akzent hinterlegt — eigene Chat-Nachrichten. -->
<Style Selector="Border.card.accent">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<Style Selector="Border.surface">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
</Style>
<Style Selector="Border.statusbar">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="24,6" />
</Style>
<Style Selector="Border.topbar">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="24,14" />
</Style>
<!-- Konsolenfläche der Log-Ansicht -->
<Style Selector="Border.console">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14" />
</Style>
<!-- Senkrechter Strich zwischen Gruppen einer Werkzeugleiste -->
<Style Selector="Border.sep">
<Setter Property="Width" Value="1" />
<Setter Property="Height" Value="20" />
<Setter Property="Margin" Value="4,0" />
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.hr">
<Setter Property="Height" Value="1" />
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Werkzeugleisten
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="StackPanel.toolbar">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="Spacing" Value="6" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style Selector="WrapPanel.toolbar">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="ItemSpacing" Value="6" />
<Setter Property="LineSpacing" Value="6" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Tabellen
Von Hand statt DataGrid: Der Entwurf zeigt eine Kopfzeile in Versalien und
Zeilen mit Haarlinie darunter — mehr nicht, kein Sortieren, kein Spaltenziehen.
Ein umgefärbtes DataGrid wäre hier deutlich mehr Aufwand als eine ListBox, und
das Ergebnis säße am Ende doch nicht auf dem Raster des Entwurfs.
══════════════════════════════════════════════════════════════════════ -->
<!-- Kopfzelle -->
<Style Selector="TextBlock.th">
<Setter Property="FontSize" Value="11" />
<Setter Property="LetterSpacing" Value="0.9" />
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Kopfzeile: trägt die Linie, die die Spaltentitel vom Inhalt trennt -->
<Style Selector="Border.thead">
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="7" />
</Style>
<!-- Zeile: Haarlinie unten, schwächer als die der Kopfzeile -->
<Style Selector="Border.tr">
<Setter Property="BorderBrush" Value="{DynamicResource RowLineBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<Style Selector="ListBox.table > ListBoxItem">
<Setter Property="Padding" Value="0" />
</Style>
<!-- Zelltext -->
<Style Selector="TextBlock.td">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.td.dim">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Etiketten (Status in Tabellen)
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Border.tag">
<Setter Property="Padding" Value="9,2" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Background" Value="{DynamicResource Neutral100Brush}" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.tag > TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{DynamicResource Neutral800Brush}" />
</Style>
<Style Selector="Border.tag.ok">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
</Style>
<Style Selector="Border.tag.ok > TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Accent700Brush}" />
</Style>
<Style Selector="Border.tag.bad">
<Setter Property="Background" Value="{DynamicResource DangerSoftBrush}" />
</Style>
<Style Selector="Border.tag.bad > TextBlock">
<Setter Property="Foreground" Value="{DynamicResource DangerTextBrush}" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Seitenleiste
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="Border.sidebar">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,1,0" />
</Style>
<!-- Gruppentitel „Arbeiten“ / „Analyse“ / „System“ -->
<Style Selector="TextBlock.navgroup">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{DynamicResource TextGhostBrush}" />
<Setter Property="Margin" Value="8,0,8,6" />
</Style>
<!-- Navigationszeile: Grundzustand durchsichtig, gewählt mit Akzent hinterlegt.
Als Button und nicht als ListBoxItem, damit Tastaturbedienung und Tooltip im
eingeklappten Zustand ohne Zusatzarbeit funktionieren. -->
<Style Selector="Button.nav">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontSize" Value="13.5" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="10,9" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
</Style>
<Style Selector="Button.nav.selected">
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Eingeklappt: nur das Symbol, mittig -->
<Style Selector="Button.nav.collapsed">
<Setter Property="Padding" Value="9" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<!-- Instanz-Schalter oben in der Seitenleiste — einziger Knopf mit Akzentrahmen -->
<Style Selector="Button.instance">
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Padding" Value="10,9" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
</Style>
<!-- ══════════════════════════════════════════════════════════════════════════
Teiler zwischen den Bereichen der Agenten-Seite
══════════════════════════════════════════════════════════════════════ -->
<Style Selector="GridSplitter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
<!-- Waagerechter Teiler: eine sichtbare Leiste mit Griffstrich, wie im Entwurf -->
<Style Selector="GridSplitter.horizontal">
<Setter Property="Height" Value="10" />
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
<Setter Property="Cursor" Value="SizeNorthSouth" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1">
<Rectangle Width="36" Height="2" Fill="{DynamicResource DividerBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter>
</Style>
<!-- Senkrechter Teiler: unsichtbar bis auf den Mauszeiger, wie im Entwurf -->
<Style Selector="GridSplitter.vertical">
<Setter Property="Width" Value="10" />
<Setter Property="Cursor" Value="SizeWestEast" />
</Style>
</Styles>
-174
View File
@@ -1,174 +0,0 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Farbwerte des Entwurfs (Mockup/…/ClawdDotNet-UI-Mockup.dc.html, Konstanten LIGHT/DARK).
Warum ThemeDictionaries und nicht zwei Dateien: Avalonia tauscht den passenden Block
aus, sobald die ThemeVariant wechselt. Jede Ansicht bindet mit DynamicResource auf
einen Namen — der Umschalter in der Kopfzeile ist damit eine einzige Zuweisung und
kein Neuaufbau der Oberflaeche.
Deckkraft-Abstufungen stehen als eigene Buerste da, nicht als Opacity am
Steuerelement: Opacity blasst auch alle Kindelemente aus, eine halbdurchsichtige
Farbe nur sich selbst.
-->
<ResourceDictionary.ThemeDictionaries>
<!-- ══════════════════════════════ HELL ══════════════════════════════ -->
<ResourceDictionary x:Key="Light">
<!-- Grundflaechen -->
<SolidColorBrush x:Key="BgBrush" Color="#f2f2f3" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#e9e9ea" />
<SolidColorBrush x:Key="TextBrush" Color="#1d1f20" />
<SolidColorBrush x:Key="AccentBrush" Color="#5980a6" />
<SolidColorBrush x:Key="Accent2Brush" Color="#728fab" />
<SolidColorBrush x:Key="DividerBrush" Color="#291d1f20" />
<Color x:Key="BgColor">#f2f2f3</Color>
<Color x:Key="SurfaceColor">#e9e9ea</Color>
<Color x:Key="TextColor">#1d1f20</Color>
<Color x:Key="AccentColor">#5980a6</Color>
<!-- Text in Abstufungen: 78 % / 72 % / 67 % / 64 % Deckkraft.
Die Werte stehen je Thema getrennt, obwohl beide Themen dieselbe Staffelung
meinen: Die Gruende sind verschieden hell, also traegt derselbe Alphawert
verschieden weit. Vorher galten hier die Werte der dunklen Palette
(b3/99/8c/80); dort reichen sie, hier ergaben drei von vier weniger als die
4,5:1, die WCAG AA fuer normalen Text verlangt — TextGhost als
PlaceholderForeground der TextBox lag bei 3,17:1.
Nachgerechnet aus den Farbwerten, gegen BgBrush und SurfaceBrush:
TextMuted 7,60 / 7,23
TextSubtle 6,20 / 5,97
TextFaint 5,32 / 5,13
TextGhost 4,84 / 4,67
Die volle Deckung liegt bei 14,79 / 13,64 — die Staffelung bleibt also
sichtbar, statt auf einen Wert zusammenzufallen. -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#c81d1f20" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#b81d1f20" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#ac1d1f20" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#a41d1f20" />
<!-- Zustaende: aus der Textfarbe abgeleitet, damit sie auf jedem Grund sitzen -->
<SolidColorBrush x:Key="HoverBrush" Color="#121d1f20" />
<SolidColorBrush x:Key="PressedBrush" Color="#241d1f20" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#0a1d1f20" />
<SolidColorBrush x:Key="RowLineBrush" Color="#141d1f20" />
<!-- Akzent, hinterlegt -->
<SolidColorBrush x:Key="AccentSoftBrush" Color="#265980a6" />
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#295980a6" />
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f5980a6" />
<!-- Neutrale Rampe -->
<SolidColorBrush x:Key="Neutral100Brush" Color="#f5f5f8" />
<SolidColorBrush x:Key="Neutral200Brush" Color="#e7e7ea" />
<SolidColorBrush x:Key="Neutral300Brush" Color="#d4d4d7" />
<SolidColorBrush x:Key="Neutral400Brush" Color="#b7b7ba" />
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
<SolidColorBrush x:Key="Neutral600Brush" Color="#7a7a7d" />
<SolidColorBrush x:Key="Neutral700Brush" Color="#5d5d60" />
<SolidColorBrush x:Key="Neutral800Brush" Color="#424244" />
<SolidColorBrush x:Key="Neutral900Brush" Color="#2b2b2d" />
<!-- Akzent-Rampe -->
<SolidColorBrush x:Key="Accent100Brush" Color="#eef6ff" />
<SolidColorBrush x:Key="Accent200Brush" Color="#d6ebff" />
<SolidColorBrush x:Key="Accent300Brush" Color="#b5d9fd" />
<SolidColorBrush x:Key="Accent400Brush" Color="#94bce3" />
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
<SolidColorBrush x:Key="Accent600Brush" Color="#597ea3" />
<SolidColorBrush x:Key="Accent700Brush" Color="#416180" />
<SolidColorBrush x:Key="Accent800Brush" Color="#2c455d" />
<SolidColorBrush x:Key="Accent900Brush" Color="#1d2d3d" />
<!-- Zustandsfarben: Statuspunkt, Meldungsleiste, Tabellen-Etiketten -->
<SolidColorBrush x:Key="SuccessBrush" Color="#3f8f5c" />
<SolidColorBrush x:Key="WarningBrush" Color="#b5822a" />
<SolidColorBrush x:Key="DangerBrush" Color="#c0392b" />
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2eb3453b" />
<SolidColorBrush x:Key="DangerTextBrush" Color="#7a2c25" />
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ab5822a" />
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ac0392b" />
</ResourceDictionary>
<!-- ══════════════════════════════ DUNKEL ══════════════════════════════ -->
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="BgBrush" Color="#1a1c1e" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#232527" />
<SolidColorBrush x:Key="TextBrush" Color="#eef0f1" />
<SolidColorBrush x:Key="AccentBrush" Color="#7ea3c6" />
<SolidColorBrush x:Key="Accent2Brush" Color="#8fa8c2" />
<SolidColorBrush x:Key="DividerBrush" Color="#29eef0f1" />
<Color x:Key="BgColor">#1a1c1e</Color>
<Color x:Key="SurfaceColor">#232527</Color>
<Color x:Key="TextColor">#eef0f1</Color>
<Color x:Key="AccentColor">#7ea3c6</Color>
<!-- Kontrast gegen BgBrush / SurfaceBrush: 7,94/7,31 · 6,13/5,79 · 5,33/5,05 ·
5,13/4,86. Nur TextGhost ist angehoben (0x80 → 0x88): Mit 4,49 auf
SurfaceBrush lag es knapp unter den 4,5:1 fuer normalen Text. -->
<SolidColorBrush x:Key="TextMutedBrush" Color="#b3eef0f1" />
<SolidColorBrush x:Key="TextSubtleBrush" Color="#99eef0f1" />
<SolidColorBrush x:Key="TextFaintBrush" Color="#8ceef0f1" />
<SolidColorBrush x:Key="TextGhostBrush" Color="#88eef0f1" />
<SolidColorBrush x:Key="HoverBrush" Color="#12eef0f1" />
<SolidColorBrush x:Key="PressedBrush" Color="#24eef0f1" />
<SolidColorBrush x:Key="RowHoverBrush" Color="#0aeef0f1" />
<SolidColorBrush x:Key="RowLineBrush" Color="#14eef0f1" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#267ea3c6" />
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#297ea3c6" />
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f7ea3c6" />
<!-- Rampen gespiegelt: 100 ist im Dunkeln die dunkelste Stufe -->
<SolidColorBrush x:Key="Neutral100Brush" Color="#2b2b2d" />
<SolidColorBrush x:Key="Neutral200Brush" Color="#424244" />
<SolidColorBrush x:Key="Neutral300Brush" Color="#5d5d60" />
<SolidColorBrush x:Key="Neutral400Brush" Color="#7a7a7d" />
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
<SolidColorBrush x:Key="Neutral600Brush" Color="#b7b7ba" />
<SolidColorBrush x:Key="Neutral700Brush" Color="#d4d4d7" />
<SolidColorBrush x:Key="Neutral800Brush" Color="#e7e7ea" />
<SolidColorBrush x:Key="Neutral900Brush" Color="#f5f5f8" />
<SolidColorBrush x:Key="Accent100Brush" Color="#1d2d3d" />
<SolidColorBrush x:Key="Accent200Brush" Color="#2c455d" />
<SolidColorBrush x:Key="Accent300Brush" Color="#416180" />
<SolidColorBrush x:Key="Accent400Brush" Color="#597ea3" />
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
<SolidColorBrush x:Key="Accent600Brush" Color="#94bce3" />
<SolidColorBrush x:Key="Accent700Brush" Color="#b5d9fd" />
<SolidColorBrush x:Key="Accent800Brush" Color="#d6ebff" />
<SolidColorBrush x:Key="Accent900Brush" Color="#eef6ff" />
<SolidColorBrush x:Key="SuccessBrush" Color="#5fb37e" />
<SolidColorBrush x:Key="WarningBrush" Color="#d1a153" />
<SolidColorBrush x:Key="DangerBrush" Color="#e0685a" />
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2ee0685a" />
<SolidColorBrush x:Key="DangerTextBrush" Color="#f0a79c" />
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ad1a153" />
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ae0685a" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- ══════════════════ Themenunabhaengig ══════════════════ -->
<!--
Barlow Condensed ueber Barlow, beide mitgeliefert statt per Systemname referenziert:
Barlow Condensed liegt auf keinem Windows und auf keinem schlanken Linux-Abbild, und
ohne die Schrift faellt der halbe Entwurf in sich zusammen.
-->
<FontFamily x:Key="HeadingFont">avares://ClawdDotNet/Assets/Fonts#Barlow Condensed</FontFamily>
<FontFamily x:Key="BodyFont">avares://ClawdDotNet/Assets/Fonts#Barlow</FontFamily>
<FontFamily x:Key="MonoFont">Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace</FontFamily>
</ResourceDictionary>
-40
View File
@@ -1,40 +0,0 @@
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;
}
@@ -1,76 +0,0 @@
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 = "Please enter an Agent ID.";
return;
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
StatusMessage = "Please enter a display name.";
return;
}
Result = new AddAgentResultData(
AgentId.Trim(),
DisplayName.Trim(),
SelectedModel,
Description.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -1,111 +0,0 @@
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 = "Execute your assigned tasks.";
[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 = "Please select an agent.";
return;
}
if (string.IsNullOrWhiteSpace(CronExpression))
{
StatusMessage = "Please enter a cron expression.";
return;
}
Result = new AddJobResultData(
SelectedAgent.AgentId,
IsToolJob,
SelectedToolName,
JobTypeId,
CronExpression.Trim(),
TaskMessage.Trim(),
RunOnStart
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -1,67 +0,0 @@
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 = "Please enter a service name.";
return;
}
if (ServicePort <= 0 || ServicePort > 65535)
{
StatusMessage = "Please enter a valid port between 1 and 65535.";
return;
}
Result = new AddServiceResultData(
ServiceName.Trim(),
ServiceType,
ServicePort,
ServiceDescription.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -1,93 +0,0 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Eine Nachricht zwischen zwei Agenten.</summary>
public sealed class AgentMessageItem
{
public required string SenderName { get; init; }
public required string Text { get; init; }
public required DateTime Time { get; init; }
/// <summary>Links steht Agent A, rechts Agent B — wie im Chat mit dem Benutzer.</summary>
public required bool IsFromAgentA { get; init; }
}
/// <summary>Ein Agent in den beiden Auswahlfeldern.</summary>
public sealed record AgentChoice(string AgentId, string DisplayName)
{
public override string ToString() => DisplayName;
}
/// <summary>
/// Verlauf der Nachrichten zwischen zwei Agenten.
///
/// <para><strong>Ohne Datenbestand.</strong> Agenten können sich heute schon Nachrichten
/// schicken — <c>AgentEngine.SendMessageAsync</c> stellt zu, aber nichts schreibt den
/// Wortlaut mit. Der Speicher <c>AgentMessages</c> aus
/// <c>docs/Agentenkommunikation-Konzept.md</c> ist noch nicht gebaut.</para>
///
/// <para>Die Seite steht trotzdem vollständig da: Auswahl, Filterung und Darstellung sind
/// fertig, und wenn der Speicher kommt, ist hier genau eine Abfrage zu ergänzen —
/// <see cref="LoadMessages"/>. Bis dahin erklärt die Seite ihren Leerzustand, statt als
/// leerer Kasten dazustehen.</para>
/// </summary>
public sealed partial class AgentChatsPageViewModel : PageViewModel
{
public ObservableCollection<AgentChoice> Agents { get; } = [];
public ObservableCollection<AgentMessageItem> Messages { get; } = [];
[ObservableProperty]
private AgentChoice? _agentA;
[ObservableProperty]
private AgentChoice? _agentB;
[ObservableProperty]
private bool _isEmpty = true;
[ObservableProperty]
private string _emptyText = "";
public AgentChatsPageViewModel() : this(null) { }
public AgentChatsPageViewModel(AppHost? host) : base("nav.agentChats")
{
if (host is null)
{
Agents.Add(new AgentChoice("dev-assistant", "Dev Assistant"));
Agents.Add(new AgentChoice("social-media", "Social Media Manager"));
}
else
{
foreach (var agent in host.Instance.Agents)
Agents.Add(new AgentChoice(agent.AgentId, agent.DisplayName));
}
AgentA = Agents.FirstOrDefault();
AgentB = Agents.Skip(1).FirstOrDefault() ?? AgentA;
LoadMessages();
}
partial void OnAgentAChanged(AgentChoice? value) => LoadMessages();
partial void OnAgentBChanged(AgentChoice? value) => LoadMessages();
/// <summary>
/// Hier kommt die Abfrage hin, sobald der <c>AgentMessages</c>-Speicher steht: alle
/// Nachrichten zwischen <see cref="AgentA"/> und <see cref="AgentB"/> in beiden
/// Richtungen, nach Zeit sortiert.
/// </summary>
private void LoadMessages()
{
Messages.Clear();
EmptyText = Agents.Count < 2
? LocalizationManager.Instance["desktop.agentChats.needTwo"]
: LocalizationManager.Instance["desktop.agentChats.empty"];
IsEmpty = Messages.Count == 0;
}
}
@@ -1,531 +0,0 @@
using ClawdDotNet.Core.Tasks;
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolItemViewModel : ViewModelBase
{
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, string description = "")
{
Name = name;
Description = description;
_isEnabled = isEnabled;
}
}
public sealed partial class AgentItemViewModel : ViewModelBase
{
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;
/// <summary>#716: What happens to a recurring task's run when the budget is exhausted.</summary>
[ObservableProperty]
private string _whenExhausted = "expire";
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
public IReadOnlyList<string> WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"];
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
/// <summary>
/// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht
/// dieselbe, und zwei gepflegte Listen wären eine zu viel.
/// </summary>
public static IReadOnlyList<string> KnownTools => AgentAdministration.KnownTools;
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
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;
_whenExhausted = config.Budget.MissPolicy == BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire";
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var description = descriptions?.GetValueOrDefault(tool) ?? "";
var item = new ToolItemViewModel(tool, isEnabled, description);
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;
Config.Budget.WhenExhausted = WhenExhausted;
}
}
/// <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
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
public ObservableCollection<AgentItemViewModel> Agents { get; } = [];
[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",
"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;
/// <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) { }
public AgentsPageViewModel(AppHost? host) : base("nav.agents")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
// Seit es eine zweite Oberflaeche gibt, ist diese Liste nicht mehr die
// einzige Quelle von Aenderungen: Ein im Browser angelegter Agent stuende
// sonst in der Datei und nicht im Fenster. Das Ereignis kommt auf dem Faden
// des Verursachers — deshalb ueber den Dispatcher.
//
// Kein Abmelden: Diese Seite lebt so lange wie die Anwendung.
host.InstanceChanged += change =>
{
if (change.Kind == InstanceChangeKind.Agents)
Dispatcher.UIThread.Post(ReloadAgents);
};
}
else
{
StatusText = LocalizationManager.Instance["desktop.status.design"];
}
LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(AllowedToolsHeader));
}
/// <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?.AgentAdmin.ToolDescriptions() ?? new Dictionary<string, string>();
/// <summary>
/// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt.
///
/// <para>Die Auswahl zu behalten ist kein Feinschliff: Der Aufbau laeuft jetzt auch,
/// wenn die Weboberflaeche etwas geaendert hat. Waere die Auswahl danach jedesmal
/// weg, spraenge dem Benutzer der Cursor aus der Zeile, waehrend er arbeitet.</para>
/// </summary>
[RelayCommand]
private void ReloadAgents()
{
if (_host is null)
{
Agents.Clear();
return;
}
var previous = SelectedAgent?.AgentId;
Agents.Clear();
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault(a => a.AgentId == previous) ?? Agents.FirstOrDefault();
}
public string AllowedToolsHeader => SelectedAgent is not null
? LocalizationManager.Instance.Get("desktop.agents.allowedToolsFor", SelectedAgent.DisplayName)
: "";
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();
OnPropertyChanged(nameof(AllowedToolsHeader));
}
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()
{
if (_host is null || RequestAddAgentDialog is null) return;
var vm = new AddAgentViewModel();
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
// Erst die offenen Änderungen der Liste sichern, dann anlegen: Der Aufruf
// schreibt die Instanzdatei, und was hier noch nicht übernommen war, ginge sonst
// verloren.
ApplyPendingEdits();
var outcome = _host.AgentAdmin.Add(
new NewAgent(result.AgentId, result.DisplayName, result.Model, result.Description));
StatusText = outcome.Message;
if (!outcome.Ok) return;
if (_host.AgentAdmin.Find(result.AgentId) is not { } newConfig) return;
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
}
[RelayCommand]
private void RemoveAgent()
{
if (_host is null || SelectedAgent is null) return;
var removed = SelectedAgent;
ApplyPendingEdits();
var outcome = _host.AgentAdmin.Remove(removed.AgentId);
StatusText = outcome.Message;
if (!outcome.Ok) return;
Agents.Remove(removed);
SelectedAgent = Agents.FirstOrDefault();
}
/// <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 RunAgentNowAsync()
{
if (_host is null || SelectedAgent is null) return;
if (_host.Engine is null)
{
StatusText = LocalizationManager.Instance["desktop.chat.noApiKey"];
return;
}
var config = SelectedAgent.Config;
IsRunning = true;
StatusText = $"Running '{config.DisplayName}'…";
try
{
var outcome = await _host.Runs.RunAgentAsync(
config.AgentId, config.Scheduler?.TaskMessage, CancellationToken.None);
StatusText = outcome.Message;
if (!outcome.Ok)
_logger?.LogWarning("Agent execution failed: {Reason}", outcome.Message);
}
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
? $"Edit Identity — {SelectedAgent.DisplayName}"
: $"Edit Soul — {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 = $"Folder not found: {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 (_host is null || SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen — das Ankreuzfeld
// zieht nach, damit Liste und Konfiguration nicht auseinanderlaufen.
SelectedTool.IsEnabled = true;
ApplyPendingEdits();
StatusText = _host.AgentAdmin
.SetToolSettings(SelectedAgent.AgentId, SelectedTool.Name, config).Message;
}
[RelayCommand]
private void SaveAgents()
{
if (_host is null) return;
ApplyPendingEdits();
_host.AgentAdmin.Save();
_logger?.LogInformation("Agents configuration saved.");
StatusText = LocalizationManager.Instance["desktop.agents.saved"];
}
/// <summary>
/// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte.
///
/// <para>Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei
/// <c>ApplyChanges</c> zurück. Jeder Aufruf, der gleich die Instanzdatei schreibt,
/// muss vorher hier durch — sonst verschwindet eine gerade getippte Änderung, weil
/// jemand nebenbei einen Agenten angelegt hat.</para>
/// </summary>
private void ApplyPendingEdits()
{
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
}
// ─── 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.
}
}
}
@@ -1,415 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
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;
/// <summary>
/// Schalter, Uhrzeit, Zielordner und Aufbewahrung stehen seit #528 (Stufe 1) hier
/// und nicht mehr in den Anwendungseinstellungen. Dort galten sie fuer jede Instanz:
/// Wer die taegliche Sicherung fuer eine einschaltete, schaltete sie fuer alle ein.
/// </summary>
private readonly InstanceConfig? _instance;
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 = LocalizationManager.Instance["common.ready"];
[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("nav.backup")
{
_host = host;
if (host is not null)
{
_instanceDir = host.InstancePath;
_instanceName = host.Instance.InstanceName;
_instance = host.Instance;
_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 (_host is null || _instance is null) return;
_loading = true;
try
{
// Aufgeloest und nicht roh angezeigt: In der Datei steht in aller Regel
// nichts, und ein leeres Feld beantwortet die Frage nicht, wohin gesichert
// wird.
TargetFolder = AppHost.ResolveBackupDirectory(_instance, _instanceDir);
AutoBackupEnabled = _instance.AutoBackupEnabled;
KeepCount = Math.Clamp(_instance.BackupKeepCount, 0, 999);
AutoBackupTime = TimeSpan.TryParse(_instance.AutoBackupTime, out var time)
? time.ToString(@"hh\:mm")
: "03:00";
}
finally
{
_loading = false;
}
}
public void SaveSettings()
{
if (_loading || _host is null || _instance is null) return;
var folder = TargetFolder.Trim();
// Der Vorgabeort bleibt als Leereintrag stehen. Sonst schriebe die Ansicht den
// aufgeloesten Pfad fest, und eine umgezogene Instanz sicherte weiter dorthin,
// wo sie einmal lag.
var vorgabe = AppHost.ResolveBackupDirectory(new InstanceConfig(), _instanceDir);
_instance.BackupDirectory =
string.Equals(folder, vorgabe, PathBoundary.Comparison) ? "" : folder;
_instance.AutoBackupEnabled = AutoBackupEnabled;
_instance.AutoBackupTime = AutoBackupTime.Trim();
_instance.BackupKeepCount = KeepCount;
_host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
}
[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"
};
}
@@ -1,158 +0,0 @@
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 = "Ready";
public ChatPageViewModel() : this(null) { }
public ChatPageViewModel(AppHost? host) : base("nav.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 = "Design Mode";
}
}
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 ? "You" : SelectedAgent.DisplayName,
entry.Content,
entry.Timestamp,
isUser
));
}
StatusText = $"Chat history for '{SelectedAgent.DisplayName}' loaded ({ChatEntries.Count} messages).";
}
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 ? "You" : (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 not active or no agent selected.";
return;
}
var text = InputPrompt.Trim();
if (string.IsNullOrWhiteSpace(text)) return;
InputPrompt = "";
IsSending = true;
StatusText = $"{SelectedAgent.DisplayName} is thinking…";
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} finished ({result.Status}, {result.TokensUsed:N0} tokens).";
}
catch (Exception ex)
{
StatusText = $"Error: {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.";
}
}
@@ -1,102 +0,0 @@
using System.Collections.ObjectModel;
using System.Reflection;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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("nav.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
? "Disabled (no API key configured)"
: "Active (AgentEngine ready)";
}
else
{
InstanceName = "Design Mode";
InstanceId = "00000000-0000-0000-0000-000000000000";
InstancePath = @"C:\ClawdDotNet\Instances\Default";
LogDirectory = @"C:\ClawdDotNet\Logs";
OpenRouterStatus = "Design";
}
LoadAssemblyBuildInfos();
}
/// <summary>
/// Fruehere Menueleiste, Punkt „Bearbeiten &gt; Instanz-Ordner oeffnen". Der Entwurf
/// kennt keine Menueleiste mehr; der Befehl sitzt jetzt dort, wo auch der Pfad steht.
/// </summary>
[RelayCommand]
private void OpenInstanceFolder()
{
if (!Directory.Exists(InstancePath)) return;
SystemShell.OpenFolder(InstancePath);
}
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));
}
}
}
@@ -1,190 +0,0 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Eine Instanz in der Auswahl.
///
/// <para>Das Monogramm und der Punkt stammen aus dem Entwurf. Der Punkt zeigt bewusst
/// <em>Bereitschaft</em> und nicht „läuft": Ob eine andere Instanz gerade läuft, weiß
/// diese Anwendung nicht — jede Instanz ist ein eigener Prozess, und einen
/// instanzübergreifenden Status gibt es noch nicht. Ein grüner Punkt für „läuft" wäre
/// hier schlicht geraten.</para>
/// </summary>
public sealed class InstanceChoiceViewModel(InstanceInfo info, bool isCurrent)
{
public InstanceInfo Info { get; } = info;
public string InstanceName => Info.InstanceName;
public string FolderPath => Info.FolderPath;
/// <summary>Erste zwei Buchstaben, gross — wie im Entwurf.</summary>
public string Monogram => Info.InstanceName.Length >= 2
? Info.InstanceName[..2].ToUpperInvariant()
: Info.InstanceName.ToUpperInvariant();
public string Summary
{
get
{
var keyStatus = IsReady
? LocalizationManager.Instance["desktop.instancePicker.apiKeyConfigured"]
: LocalizationManager.Instance["desktop.instancePicker.apiKeyMissing"];
return string.Format(
LocalizationManager.Instance["desktop.instancePicker.agentsCount"],
Info.AgentCount,
keyStatus);
}
}
/// <summary>Ohne API-Key kann die Instanz keine Agenten starten.</summary>
public bool IsReady => Info.ApiKeyStatus.Equals("Konfiguriert", StringComparison.OrdinalIgnoreCase)
|| Info.ApiKeyStatus.Equals("Configured", StringComparison.OrdinalIgnoreCase);
/// <summary>Die gerade laufende Instanz — traegt im Entwurf das Etikett „Aktiv".</summary>
public bool IsCurrent { get; } = isCurrent;
}
/// <summary>
/// Auswahl der Instanz — beim Start und beim Wechseln aus der laufenden Anwendung.
///
/// <para>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.</para>
/// </summary>
public sealed partial class InstancePickerViewModel : ViewModelBase
{
private readonly InstanceDirectoryManager _directories;
private readonly TaskCompletionSource<string?> _completion = new();
private readonly string? _currentInstancePath;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
private InstanceChoiceViewModel? _selected;
[ObservableProperty]
private string _newInstanceName = "";
/// <summary>Fehlertext für die Ansicht; leer heißt: alles in Ordnung.</summary>
[ObservableProperty]
private string _errorText = "";
public ObservableCollection<InstanceChoiceViewModel> 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;
/// <summary>
/// Beim Start heisst die Schaltflaeche „Starten", aus der laufenden Anwendung heraus
/// „Wechseln" — und dann steht der Hinweis dabei, dass dafuer neu gestartet wird.
/// </summary>
public bool IsSwitchMode => _currentInstancePath is not null;
public string ConfirmLabel => IsSwitchMode
? LocalizationManager.Instance["desktop.instancePicker.confirmSwitch"]
: LocalizationManager.Instance["desktop.instancePicker.confirmStart"];
/// <summary>Ueberschrift des Dialogs — nicht aus ConfirmLabel zusammengesetzt,
/// sonst stuende dort „Instanz Starten".</summary>
public string Title => IsSwitchMode
? LocalizationManager.Instance["desktop.instancePicker.titleSwitch"]
: LocalizationManager.Instance["desktop.instancePicker.titleSelect"];
public string Hint => IsSwitchMode
? LocalizationManager.Instance["desktop.instancePicker.hintSwitch"]
: LocalizationManager.Instance["desktop.instancePicker.hintSelect"];
/// <param name="currentInstancePath">
/// Pfad der laufenden Instanz, oder <c>null</c> beim Start. Steuert Beschriftung und
/// das Etikett „Aktiv" in der Liste.
/// </param>
public InstancePickerViewModel(InstanceDirectoryManager directories, string? currentInstancePath = null)
{
_directories = directories;
_currentInstancePath = currentInstancePath;
Refresh();
}
[RelayCommand]
private void Refresh()
{
Instances.Clear();
foreach (var instance in _directories.ListInstances())
{
var isCurrent = _currentInstancePath is not null
&& string.Equals(instance.FolderPath, _currentInstancePath,
StringComparison.OrdinalIgnoreCase);
Instances.Add(new InstanceChoiceViewModel(instance, isCurrent));
}
// Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick
// weniger bei jedem Start.
Selected ??= Instances.FirstOrDefault(i => i.IsCurrent) ?? Instances.FirstOrDefault();
}
private bool CanStart => Selected is not null;
[RelayCommand(CanExecute = nameof(CanStart))]
private void Start() => _completion.TrySetResult(Selected!.FolderPath);
/// <summary>Waehlt eine Zeile und bestaetigt in einem Zug — die Schaltflaeche je Zeile.</summary>
[RelayCommand]
private void Activate(InstanceChoiceViewModel? choice)
{
if (choice is null) return;
Selected = choice;
_completion.TrySetResult(choice.FolderPath);
}
/// <summary>Abbruch — der Aufrufer beendet die Anwendung oder schliesst den Dialog.</summary>
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
[RelayCommand]
private void Create()
{
var name = NewInstanceName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
ErrorText = LocalizationManager.Instance["desktop.instancePicker.nameRequired"];
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 = string.Format(
LocalizationManager.Instance["desktop.instancePicker.createFailed"],
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;
}
@@ -1,46 +0,0 @@
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);
}
@@ -1,116 +0,0 @@
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; } = [LocalizationManager.Instance["common.all"]];
public IReadOnlyList<string> Levels { get; } = [LocalizationManager.Instance["common.all"], "Info", "Warn", "Error"];
[ObservableProperty]
private string _selectedModule = LocalizationManager.Instance["common.all"];
[ObservableProperty]
private string _selectedLevel = LocalizationManager.Instance["common.all"];
/// <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("nav.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 isAllModule = string.Equals(SelectedModule, LocalizationManager.Instance["common.all"], StringComparison.OrdinalIgnoreCase) ||
string.Equals(SelectedModule, "All", StringComparison.OrdinalIgnoreCase) ||
string.Equals(SelectedModule, "Alle", StringComparison.OrdinalIgnoreCase);
var module = isAllModule ? 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;
var allText = LocalizationManager.Instance["common.all"];
Modules.Clear();
Modules.Add(allText);
foreach (var module in _tail.AvailableModules())
Modules.Add(module);
// Auswahl halten, wenn es das Modul noch gibt.
SelectedModule = Modules.Contains(current) ? current : allText;
}
[RelayCommand]
private void OpenFolder() => SystemShell.OpenFolder(_logDirectory);
}
@@ -1,396 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Desktop.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
// Ohne Alias verweist "App" hier auf ClawdDotNet.Desktop.App, die Anwendungsklasse
// von Avalonia — und nicht auf die gleichnamige Fachschicht.
using AppSettings = ClawdDotNet.App.Settings.AppSettings;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Das Gerüst des Hauptfensters: Seitenleiste, Kopfzeile, Meldungen, Statusleiste.
///
/// <para>Die Menüleiste ist entfallen. Der Entwurf kennt keine, und ihre Einträge hatten
/// ohnehin überall sonst schon einen Platz: Der Instanzwechsel sitzt jetzt als eigener
/// Schalter oben in der Seitenleiste, die Ordner-Befehle stehen dort, wo die Ordner
/// gebraucht werden (Logs-Seite, Info-Seite), und „Ansicht" war eine zweite Navigation
/// neben der Navigation.</para>
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppHost? _host;
/// <summary>Breite der Seitenleiste, ausgeklappt und eingeklappt (siehe Entwurf).</summary>
private const double SidebarWidthExpanded = 240;
private const double SidebarWidthCollapsed = 64;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PageTitle))]
private PageViewModel? _selectedPage;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SidebarWidth))]
[NotifyPropertyChangedFor(nameof(IsSidebarExpanded))]
private bool _isSidebarCollapsed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ThemeLabel))]
[NotifyPropertyChangedFor(nameof(ThemeIconKey))]
private bool _isDarkTheme;
// ─── Statusleiste ───
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstanceStatusLabel))]
private string _instanceName = "—";
[ObservableProperty]
private bool _isApiReachable;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ApiStatusDisplay))]
private string _apiStatusText = "unbekannt";
public string InstanceStatusLabel => LocalizationManager.Instance.Get("desktop.mainWindow.instanceLabel", InstanceName);
public string ApiStatusDisplay => LocalizationManager.Instance.Get("desktop.mainWindow.apiStatus", ApiStatusText);
/// <summary>Kurzinfo zu Tokens und Kosten der letzten Stunde bzw. 24 Stunden.</summary>
[ObservableProperty]
private string _usageText = "—";
[ObservableProperty]
private string _usageTooltip = "";
[ObservableProperty]
private string _creditsText = "—";
/// <summary>Alle Bereiche, in der Reihenfolge der Navigation.</summary>
public ObservableCollection<PageViewModel> Pages { get; } = [];
/// <summary>Die Navigation, gruppiert wie im Entwurf.</summary>
public ObservableCollection<NavGroupViewModel> NavGroups { get; } = [];
/// <summary>
/// Instanzübergreifende Hinweise. Bleibt vorerst leer — der Dienst, der Fehler und
/// aufgebrauchte Guthaben <em>anderer</em> Instanzen sieht, existiert noch nicht
/// (siehe Implementierungsleitfaden im Mockup-Verzeichnis). Die Leiste blendet sich
/// aus, solange nichts anliegt, und ist damit heute schon unauffällig richtig.
/// </summary>
public ObservableCollection<NotificationViewModel> Notifications { get; } = [];
public bool HasNotifications => Notifications.Count > 0;
public double SidebarWidth => IsSidebarCollapsed ? SidebarWidthCollapsed : SidebarWidthExpanded;
public bool IsSidebarExpanded => !IsSidebarCollapsed;
public string PageTitle => SelectedPage?.Title ?? "";
public string ThemeLabel => IsDarkTheme
? LocalizationManager.Instance["desktop.mainWindow.themeDark"]
: LocalizationManager.Instance["desktop.mainWindow.themeLight"];
/// <summary>Fusszeile der Seitenleiste.</summary>
public string VersionText => $"ClawdDotNet {AppHost.AppVersion} · Avalonia";
/// <summary>Sonne im Hellmodus, Mond im Dunkelmodus — beides Namen aus Icons.axaml.</summary>
public string ThemeIconKey => IsDarkTheme ? "IconMoon" : "IconSun";
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
/// <summary>
/// Ob der Instanzwechsel aus diesem Fenster heraus angeboten wird.
///
/// <para><c>false</c>, sobald der Instanzmanager diese Instanz führt (#528, E-M2):
/// Dann hält er die Fenster aller Instanzen im Hintergrund und holt das gewünschte
/// nach vorn. Ein Wechsel von hier aus wäre ein Selbst-Neustart und nähme genau die
/// Instanz mit, aus der heraus jemand wechselt.</para>
/// </summary>
public bool CanSwitchInstance { get; init; } = true;
/// <summary>Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.</summary>
public MainWindowViewModel() : this(null) { }
public MainWindowViewModel(AppHost? host)
{
_host = host;
var chat = new ChatPageViewModel(host);
var agents = new AgentsPageViewModel(host);
var tasks = new TasksPageViewModel(host);
var tokens = new TokenUsagePageViewModel(host);
var agentChats = new AgentChatsPageViewModel(host);
var backup = new BackupPageViewModel(host);
var logs = new LogPageViewModel(host);
var settings = new SettingsPageViewModel(host);
var info = new InfoPageViewModel(host);
foreach (var page in new PageViewModel[]
{ chat, agents, tasks, tokens, agentChats, backup, logs, settings, info })
Pages.Add(page);
NavGroups.Add(new NavGroupViewModel("desktop.nav.work",
[
new NavItemViewModel(this, chat, "IconChat"),
new NavItemViewModel(this, agents, "IconAgents"),
new NavItemViewModel(this, tasks, "IconTasks")
]));
NavGroups.Add(new NavGroupViewModel("desktop.nav.analytics",
[
new NavItemViewModel(this, tokens, "IconChart"),
new NavItemViewModel(this, agentChats, "IconAgentChats")
]));
NavGroups.Add(new NavGroupViewModel("desktop.nav.system",
[
new NavItemViewModel(this, backup, "IconBackup"),
new NavItemViewModel(this, logs, "IconLogs"),
new NavItemViewModel(this, settings, "IconSettings"),
new NavItemViewModel(this, info, "IconInfo")
]));
LocalizationManager.Instance.LanguageChanged += () =>
{
OnPropertyChanged(nameof(PageTitle));
OnPropertyChanged(nameof(ThemeLabel));
OnPropertyChanged(nameof(InstanceStatusLabel));
OnPropertyChanged(nameof(ApiStatusDisplay));
if (_host is null)
{
InstanceName = LocalizationManager.Instance["desktop.status.design"];
ApiStatusText = LocalizationManager.Instance["desktop.status.design"];
}
};
// Die Leiste blendet sich aus, solange nichts anliegt — dafür muss die Ansicht
// von Zu- und Abgängen erfahren, nicht nur von der Sammlung selbst.
Notifications.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasNotifications));
SelectedPage = chat;
if (host is null)
{
InstanceName = "Entwurfsmodus";
ApiStatusText = "Entwurf";
UsageText = "1h: 3.240 Tok · 24h: 168.500 Tok";
CreditsText = "Credits: $41.86 von $100.00";
// Zwei Beispielmeldungen, damit die Leiste im Editor sichtbar ist.
Notifications.Add(new NotificationViewModel(
this, isError: true, "sandbox", "OpenRouter-Guthaben aufgebraucht — Agenten pausiert."));
Notifications.Add(new NotificationViewModel(
this, isError: false, "stock-team", "3 unbehandelte Exceptions in den letzten 15 Minuten."));
return;
}
InstanceName = host.Instance.InstanceName;
IsSidebarCollapsed = host.Settings.AppSettings.SidebarCollapsed;
IsDarkTheme = ThemeManager.IsDarkName(host.Settings.AppSettings.Theme);
ThemeManager.Apply(IsDarkTheme);
ApplyCollapsedToNav();
HookStatusService(host);
}
/// <summary>
/// Hängt die Statusleiste an den OpenRouter-Dienst.
///
/// <para>Der Dienst war fertig und ungenutzt — er lieferte seine Texte bislang an
/// niemanden. <c>OnStatusUpdated</c> kommt vom Hintergrundfaden des
/// <c>PeriodicTimer</c>, deshalb der Wechsel auf den Oberflächenfaden.</para>
/// </summary>
private void HookStatusService(AppHost host)
{
if (host.Status is not { } status)
{
ApiStatusText = LocalizationManager.Instance["desktop.status.noApiKey"];
UsageText = LocalizationManager.Instance["desktop.status.noUsageData"];
CreditsText = "";
return;
}
void Update()
{
IsApiReachable = status.IsApiReachable;
ApiStatusText = status.IsApiReachable
? LocalizationManager.Instance["desktop.status.reachable"]
: LocalizationManager.Instance["desktop.status.unreachable"];
UsageText = status.CreditsText;
UsageTooltip = status.CreditsTooltip;
CreditsText = status.CreditRemaining is { } remaining && status.CreditBalance is { } total
? LocalizationManager.Instance.Get("desktop.mainWindow.credits", remaining, total)
: LocalizationManager.Instance["desktop.mainWindow.creditsNone"];
}
status.OnStatusUpdated += () => Dispatcher.UIThread.Post(Update);
Update();
}
partial void OnSelectedPageChanged(PageViewModel? value)
{
foreach (var item in NavGroups.SelectMany(g => g.Items))
item.IsSelected = ReferenceEquals(item.Page, value);
}
partial void OnIsSidebarCollapsedChanged(bool value)
{
ApplyCollapsedToNav();
Persist(s => s.SidebarCollapsed = value);
}
partial void OnIsDarkThemeChanged(bool value)
{
ThemeManager.Apply(value);
Persist(s => s.Theme = ThemeManager.ToName(value));
}
/// <summary>
/// Der eingeklappte Zustand steht an jedem Eintrag statt an einer Bindung auf das
/// Elternfenster: Aus einer DataTemplate heraus zurück auf das Fenster zu binden ist
/// mit übersetzten Bindungen jedes Mal ein Ausdruck, den niemand mehr liest.
/// </summary>
private void ApplyCollapsedToNav()
{
foreach (var group in NavGroups)
{
group.IsTitleVisible = !IsSidebarCollapsed;
foreach (var item in group.Items)
item.IsCollapsed = IsSidebarCollapsed;
}
}
/// <summary>
/// Einstellungen sind ein Nebeneffekt der Bedienung, kein eigener Vorgang: Wer die
/// Leiste einklappt, drückt nicht anschließend auf Speichern. Ein Schreibfehler darf
/// die Bedienung deshalb auch nicht unterbrechen.
/// </summary>
private void Persist(Action<AppSettings> change)
{
if (_host is null) return;
try
{
change(_host.Settings.AppSettings);
_host.Settings.Save();
}
catch (Exception)
{
// Der Zustand gilt für diese Sitzung trotzdem — nur eben nicht für die nächste.
}
}
[RelayCommand]
private void SelectPage(PageViewModel? page)
{
if (page is not null) SelectedPage = page;
}
[RelayCommand]
private void ToggleSidebar() => IsSidebarCollapsed = !IsSidebarCollapsed;
[RelayCommand]
private void ToggleTheme() => IsDarkTheme = !IsDarkTheme;
[RelayCommand]
private void OpenInstanceFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.InstancePath);
}
[RelayCommand]
private void OpenInstanceManager() => RequestInstanceManagerDialog?.Invoke();
[RelayCommand]
private void Exit() => RequestExit?.Invoke();
}
/// <summary>Eine Gruppe der Navigation („Arbeiten", „Analyse", „System").</summary>
public sealed partial class NavGroupViewModel : ObservableObject
{
private readonly string _titleKey;
public string Title => LocalizationManager.Instance.Get(_titleKey);
public IReadOnlyList<NavItemViewModel> Items { get; }
/// <summary>Im eingeklappten Zustand entfällt die Gruppenüberschrift.</summary>
[ObservableProperty]
private bool _isTitleVisible = true;
public NavGroupViewModel(string titleKey, IReadOnlyList<NavItemViewModel> items)
{
_titleKey = titleKey;
Items = items;
LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(Title));
}
}
/// <summary>
/// Ein Eintrag der Navigation.
///
/// <para>Der Befehl sitzt am Eintrag selbst und nicht am Fenster: Aus einer DataTemplate
/// heraus über <c>$parent[…]</c> auf einen Befehl des Fensters zu binden funktioniert
/// zwar, ist aber bei übersetzten Bindungen ein Ausdruck mit Typumwandlung, den man beim
/// nächsten Lesen erst wieder entziffern muss.</para>
/// </summary>
public sealed partial class NavItemViewModel : ObservableObject
{
private readonly MainWindowViewModel _owner;
public PageViewModel Page { get; }
public string Title => Page.Title;
/// <summary>Name der Geometrie in <c>Styles/Icons.axaml</c>.</summary>
public string IconKey { get; }
[ObservableProperty]
private bool _isSelected;
[ObservableProperty]
private bool _isCollapsed;
public NavItemViewModel(MainWindowViewModel owner, PageViewModel page, string iconKey)
{
_owner = owner;
Page = page;
IconKey = iconKey;
LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(Title));
}
[RelayCommand]
private void Select() => _owner.SelectedPage = Page;
}
/// <summary>Eine Zeile der Benachrichtigungsleiste.</summary>
public sealed partial class NotificationViewModel(
MainWindowViewModel owner, bool isError, string instanceName, string text) : ObservableObject
{
public bool IsError { get; } = isError;
public bool IsWarning => !IsError;
public string InstanceName { get; } = instanceName;
public string Text { get; } = text;
[RelayCommand]
private void Dismiss() => owner.Notifications.Remove(this);
}
/// <summary>Ein Bereich des Hauptfensters.</summary>
public abstract partial class PageViewModel : ViewModelBase
{
private readonly string _titleKey;
public string Title => LocalizationManager.Instance.Get(_titleKey);
protected PageViewModel(string titleKey)
{
_titleKey = titleKey;
LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(Title));
}
}
@@ -1,82 +0,0 @@
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}, passphrase protected"
: _manifest.SecretCount > 0
? $"{_manifest.SecretCount} excluded"
: "none";
public string InstanceInfoText => string.Format(L["desktop.dialog.backupInstance"], InstanceName);
public string CreatedAtText => string.Format(L["desktop.dialog.backupCreated"], CreatedAt);
public string FileCountText => string.Format(L["desktop.dialog.backupFiles"], FileCount);
public string SecretsText => string.Format(L["desktop.dialog.backupSecrets"], SecretsDescription);
[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 = "Please specify a target directory.";
return;
}
if (NeedsPassphrase && string.IsNullOrEmpty(Passphrase))
{
StatusMessage = "Please enter the passphrase for protected credentials.";
return;
}
Result = new RestoreResultData(
TargetDirectory.Trim(),
NeedsPassphrase ? Passphrase : null,
Overwrite);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -1,227 +0,0 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using ClawdDotNet.Desktop.Services;
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;
/// <summary>Womit die Seite geladen wurde — nur, um eine Änderung zu erkennen.</summary>
private string _logDirectoryAtLoad = "";
// ─── Allgemein & Pfade ───
[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;
// Die Sicherungs-Einstellungen (Zielordner, Uhrzeit, Anzahl) stehen bewusst
// NICHT hier, sondern allein auf der Sicherungs-Seite. Sie lagen frueher
// doppelt vor: Die Felder waren aus der Ansicht bereits verschwunden, wurden
// aber weiter beim Start gelesen und beim Speichern zurueckgeschrieben - wer
// die Uhrzeit auf der Sicherungs-Seite aenderte und danach hier speicherte,
// bekam den alten Wert zurueck.
// ─── 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 = "";
/// <summary>
/// Wohin diese Instanz protokolliert. Steht seit #528 (Stufe 1) hier und nicht mehr
/// bei den Anwendungseinstellungen: Ein Verzeichnis für alle hiess, dass in
/// Startup.log abwechselnd zwei Instanzen schrieben, ohne Kennzeichnung, welche
/// gerade spricht.
///
/// <para>Leer angezeigt heisst leer gemeint — die Vorgabe steht als Hinweis unter
/// dem Feld. Anders als bei der Sicherungsseite wird hier bewusst nicht aufgeloest:
/// Der geltende Ort steht auf der Info-Seite, und ein aufgeloester Pfad im Feld
/// wuerde beim naechsten Speichern festgeschrieben.</para>
/// </summary>
[ObservableProperty]
private string _logDirectory = "";
/// <summary>
/// Ob der Instanzmanager diese Instanz am Laufen hält (#528).
///
/// <para>Steht hier und nicht beim Manager, weil es eine Eigenschaft dieser Instanz
/// ist — und weil die Instanzdatei dem Instanzprozess gehört: Sie vom Manager
/// schreiben zu lassen, während die Instanz läuft, waeren zwei Schreiber auf einer
/// Datei. Der Manager liest den Schalter nur.</para>
/// </summary>
[ObservableProperty]
private bool _autostartByManager;
[ObservableProperty]
private string _openRouterApiKey = "";
[ObservableProperty]
private decimal _instanceDailyCostUsd;
[ObservableProperty]
private long _instanceDailyTokens;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private string _selectedLanguage = LocalizationManager.DefaultLanguage;
public IReadOnlyList<LanguageOption> AvailableLanguages => LocalizationManager.Instance.AvailableLanguages;
public LanguageOption? SelectedLanguageOption
{
get => AvailableLanguages.FirstOrDefault(l => l.Code.Equals(SelectedLanguage, StringComparison.OrdinalIgnoreCase)) ?? AvailableLanguages.FirstOrDefault();
set
{
if (value is not null && value.Code != SelectedLanguage)
{
SelectedLanguage = value.Code;
OnPropertyChanged();
LocalizationManager.Instance.SetLanguage(value.Code);
}
}
}
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("nav.settings")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Settings");
LoadFromHost();
}
else
{
StatusText = LocalizationManager.Instance["desktop.status.design"];
}
}
[RelayCommand]
private void LoadFromHost()
{
if (_host is null) return;
var s = _host.Settings.AppSettings;
InstancesDirectory = s.InstancesDirectory;
MinimumLogLevel = s.MinimumLogLevel;
MaxLogLinesInUi = s.MaxLogLinesInUi;
LogRefreshIntervalMs = s.LogRefreshIntervalMs;
OpenRouterBaseUrl = s.OpenRouterBaseUrl;
StatusCheckIntervalSeconds = s.StatusCheckIntervalSeconds;
DeploymentcenterUrl = s.DeploymentcenterUrl;
DeploymentcenterToken = s.DeploymentcenterToken;
DeploymentcenterEnvironment = s.DeploymentcenterEnvironment;
ErrorReportingEnabled = s.ErrorReportingEnabled;
UpdateCheckEnabled = s.UpdateCheckEnabled;
UpdateChannel = s.UpdateChannel;
LicenseKey = s.LicenseKey;
SelectedLanguage = s.Language ?? LocalizationManager.DefaultLanguage;
OnPropertyChanged(nameof(SelectedLanguageOption));
var inst = _host.Instance;
InstanceName = inst.InstanceName;
LogDirectory = inst.LogDirectory;
AutostartByManager = inst.AutostartByManager;
_logDirectoryAtLoad = inst.LogDirectory;
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.InstancesDirectory = InstancesDirectory.Trim();
s.MinimumLogLevel = MinimumLogLevel;
s.MaxLogLinesInUi = MaxLogLinesInUi;
s.LogRefreshIntervalMs = LogRefreshIntervalMs;
s.OpenRouterBaseUrl = OpenRouterBaseUrl.Trim();
s.StatusCheckIntervalSeconds = StatusCheckIntervalSeconds;
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();
s.Language = SelectedLanguage;
_host.Settings.Save();
LocalizationManager.Instance.SetLanguage(SelectedLanguage);
var inst = _host.Instance;
inst.InstanceName = InstanceName.Trim();
inst.LogDirectory = LogDirectory.Trim();
inst.AutostartByManager = AutostartByManager;
inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
inst.Budget.DailyTokens = InstanceDailyTokens;
_host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
_logger?.LogInformation("Settings and instance configuration saved.");
// Das Protokoll-Verzeichnis greift erst beim naechsten Start: Die
// Protokollierung steht seit dem Aufbau des AppHost und schreibt bereits.
StatusText = string.Equals(inst.LogDirectory, _logDirectoryAtLoad, StringComparison.Ordinal)
? LocalizationManager.Instance["desktop.settings.saved"]
: LocalizationManager.Instance["desktop.settings.savedLogRestart"];
_logDirectoryAtLoad = inst.LogDirectory;
}
}
@@ -1,398 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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
);
/// <summary>Die drei Ansichten der Aufgaben-Seite.</summary>
public enum TasksTab
{
Jobs,
Services,
History
}
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 ILogger? _logger;
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;
/// <summary>
/// Der Entwurf ersetzt die Registerkarten durch eine Segmentleiste. Fachlich ist das
/// dasselbe: eine von drei Ansichten. Die drei Kennzeichen darunter gibt es, weil
/// eine Bindung „Ist dieser Wert gleich Services?" in AXAML sonst jedes Mal einen
/// Konverter mit Parameter bräuchte.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsJobsTab))]
[NotifyPropertyChangedFor(nameof(IsServicesTab))]
[NotifyPropertyChangedFor(nameof(IsHistoryTab))]
private TasksTab _selectedTab = TasksTab.Jobs;
public bool IsJobsTab => SelectedTab == TasksTab.Jobs;
public bool IsServicesTab => SelectedTab == TasksTab.Services;
public bool IsHistoryTab => SelectedTab == TasksTab.History;
public event Func<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? RequestAddServiceDialog;
public TasksPageViewModel() : this(null) { }
public TasksPageViewModel(AppHost? host) : base("nav.tasks")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
host.ServiceAdmin.EnsureBuiltIns();
RefreshJobList();
RefreshServiceList();
RefreshJobHistory();
// Auch die Weboberflaeche legt Jobs und Dienste an. Das Ereignis kommt auf
// dem Faden des Verursachers, deshalb ueber den Dispatcher.
//
// Kein Abmelden: Diese Seite lebt so lange wie die Anwendung.
host.InstanceChanged += change => Dispatcher.UIThread.Post(() =>
{
switch (change.Kind)
{
case InstanceChangeKind.Jobs:
case InstanceChangeKind.Agents:
// Auch bei Agenten: Verschwindet ein Agent, verschwinden seine
// geplanten Laeufe mit ihm.
RefreshJobList();
break;
case InstanceChangeKind.Services:
RefreshServiceList();
break;
}
});
}
else
{
StatusText = LocalizationManager.Instance["desktop.status.design"];
}
}
[RelayCommand]
private void RefreshJobList()
{
var previous = SelectedJob?.JobId;
JobEntries.Clear();
if (_host is null) return;
foreach (var job in _host.JobAdmin.List())
JobEntries.Add(ToDisplayItem(job, _host.Runs.LastRun(job.JobId)));
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == previous);
}
/// <summary>
/// Formt einen geplanten Lauf für die Anzeige um.
///
/// <para>Die Fachschicht liefert Zeitpunkte als <see cref="DateTime"/>; wie sie
/// aussehen und was an ihrer Stelle steht, wenn es keinen gibt, entscheidet die
/// Anzeige. Deshalb steht das Gedankenstrich-Zeichen hier und nicht dort.</para>
/// </summary>
private static JobDisplayItemViewModel ToDisplayItem(JobSummary job, DateTime? lastRun)
{
var isToolJob = job.Kind == JobKind.ToolJob;
var nextRun = !job.CronValid
? "Ungültig"
: job.NextRunUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
return new JobDisplayItemViewModel(
isToolJob ? "Tool Job" : "Agent Wakeup",
job.JobId,
job.AgentId,
job.AgentName,
isToolJob ? job.ToolName : "—",
job.Cron,
job.Message,
job.RunOnStart,
nextRun,
lastRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "—",
"—",
job.Enabled
? "Aktiv"
: isToolJob ? "Deaktiviert" : "Inaktiv");
}
[RelayCommand]
private void RefreshServiceList()
{
var previous = SelectedService?.ServiceId;
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
));
}
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == previous);
}
[RelayCommand]
private void RefreshJobHistory()
{
JobHistoryEntries.Clear();
if (_host is null) return;
foreach (var entry in _host.Runs.History.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 outcome = _host.JobAdmin.Add(ToDefinition(result));
StatusText = outcome.Message;
if (outcome.Ok)
_logger?.LogInformation("Geplanter Lauf hinzugefügt: Agent={Agent}, Cron={Cron}",
result.AgentId, result.CronExpression);
RefreshJobList();
}
/// <summary>Übersetzt das Ergebnis des Dialogs in das, womit die Fachschicht arbeitet.</summary>
private static JobDefinition ToDefinition(AddJobResultData result)
=> new(
result.AgentId,
result.IsToolJob ? JobKind.ToolJob : JobKind.AgentWakeup,
result.ToolName,
result.JobTypeId,
result.CronExpression,
result.TaskMessage,
result.RunOnStart);
[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;
StatusText = _host.JobAdmin.Update(entry.JobId, ToDefinition(result)).Message;
RefreshJobList();
}
[RelayCommand]
private void RemoveJob()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
StatusText = _host.JobAdmin.Remove(
entry.AgentId,
entry.JobId,
entry.JobType == "Tool Job" ? JobKind.ToolJob : JobKind.AgentWakeup).Message;
RefreshJobList();
}
[RelayCommand]
private async Task RunJobNowAsync()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var isToolJob = entry.JobType == "Tool Job";
IsRunningJob = true;
StatusText = $"Führe Job für '{entry.AgentName}' aus…";
try
{
var outcome = isToolJob
? await _host.Runs.RunToolJobAsync(entry.AgentId, entry.JobId, CancellationToken.None)
: await _host.Runs.RunAgentAsync(entry.AgentId, entry.TaskMessage, CancellationToken.None);
StatusText = outcome.Message;
if (!outcome.Ok)
_logger?.LogWarning("Job-Ausführung fehlgeschlagen: {Grund}", outcome.Message);
RefreshJobList();
RefreshJobHistory();
}
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;
StatusText = _host.ServiceAdmin.Add(new NewService(
result.ServiceName,
result.ServiceType,
result.ServicePort,
result.ServiceDescription)).Message;
RefreshServiceList();
}
[RelayCommand]
private void RemoveService()
{
if (_host is null || SelectedService is null) return;
StatusText = _host.ServiceAdmin.Remove(SelectedService.ServiceId).Message;
RefreshServiceList();
}
/// <summary>
/// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue
/// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste.
///
/// <para>Gilt nur für Tool-Jobs: <c>SchedulerConfig</c> hat keinen solchen Schalter,
/// ein Agent-Wakeup existiert oder existiert nicht. Ihn hier zu ergänzen wäre keine
/// Oberflächenarbeit — der Scanner müsste den Schalter erst einmal lesen, sonst liefe
/// ein „deaktivierter" Wakeup munter weiter.</para>
/// </summary>
[RelayCommand]
private void ToggleJobEnabled()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
StatusText = _host.JobAdmin
.SetEnabled(entry.AgentId, entry.JobId, enabled: entry.Status != "Aktiv").Message;
RefreshJobList();
// Die Auswahl geht beim Neuaufbau der Liste verloren — sie wieder zu setzen
// erspart es, für zwei Schaltvorgänge zweimal dieselbe Zeile anzuklicken.
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == entry.JobId);
}
/// <summary>
/// Aktiviert bzw. deaktiviert einen Dienst.
///
/// <para>Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt:
/// Umgelegt wird das Kennzeichen <c>Enabled</c> in der Instanzkonfiguration. Ein
/// laufender Dienstprozess wird davon nicht angehalten — eine Schaltfläche „Stop",
/// nach der etwas weiterläuft, wäre schlimmer als gar keine.</para>
/// </summary>
[RelayCommand]
private void ToggleServiceEnabled()
{
if (_host is null || SelectedService is null) return;
var id = SelectedService.ServiceId;
StatusText = _host.ServiceAdmin
.SetEnabled(id, enabled: SelectedService.Status != "Bereit").Message;
RefreshServiceList();
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
}
[RelayCommand]
private void SelectTab(string? tab)
{
SelectedTab = tab switch
{
"Services" => TasksTab.Services,
"History" => TasksTab.History,
_ => TasksTab.Jobs
};
}
}
@@ -1,201 +0,0 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Accounting;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Ein Balken des Wochendiagramms.</summary>
public sealed class TokenDayItem
{
public required string DayLabel { get; init; }
public required string TokensLabel { get; init; }
public required int Tokens { get; init; }
/// <summary>
/// Höhe in Bildpunkten, nicht in Prozent: Ein Balken kann sich nicht auf die Höhe
/// seines Elternteils beziehen, ohne dass die Zeile eine feste Höhe bekommt — und
/// eine feste Höhe im Ansichtsmodell auszurechnen ist ehrlicher als eine, die in
/// drei Bindungen verteilt steht.
/// </summary>
public required double BarHeight { get; init; }
}
/// <summary>Eine Zeile der Aufschlüsselung nach Agent und Modell.</summary>
public sealed class TokenAgentItem
{
public required string AgentName { get; init; }
public required string Model { get; init; }
public required string TokensLabel { get; init; }
public required string CostLabel { get; init; }
public required int Runs { get; init; }
}
/// <summary>
/// Tokennutzung der letzten sieben Tage.
///
/// <para>Liest aus <see cref="SqliteUsageRepository"/> — dieselbe Quelle, aus der die
/// Tagesbudgets bedient werden. Kein Diagramm-Paket: Der Entwurf zeigt sieben Balken und
/// eine Tabelle, und dafür genügen Rechtecke mit ausgerechneter Höhe. Eine Bibliothek
/// dafür wäre 8 MB Abhängigkeit für sieben Rechtecke.</para>
/// </summary>
public sealed partial class TokenUsagePageViewModel : PageViewModel
{
/// <summary>Höhe der Diagrammfläche in Bildpunkten (siehe Entwurf: 160 px inkl. Beschriftung).</summary>
private const double ChartHeight = 130;
/// <summary>Wie viele Läufe für die Aufschlüsselung herangezogen werden.</summary>
private const int RecentRunLimit = 1000;
private const int Days = 7;
private readonly AppHost? _host;
public ObservableCollection<TokenDayItem> Days7 { get; } = [];
public ObservableCollection<TokenAgentItem> ByAgent { get; } = [];
[ObservableProperty]
private string _totalTokens = "—";
[ObservableProperty]
private string _totalCost = "—";
[ObservableProperty]
private string _totalRuns = "—";
[ObservableProperty]
private string _statusText = "";
[ObservableProperty]
private bool _isEmpty = true;
public TokenUsagePageViewModel() : this(null) { }
public TokenUsagePageViewModel(AppHost? host) : base("nav.usage")
{
_host = host;
if (host?.Usage is null)
{
StatusText = host is null
? "Design mode"
: "No usage database for this instance.";
return;
}
// Nicht abwarten: Der Aufbau des Fensters darf nicht auf eine Datenbankabfrage
// warten. Die Seite füllt sich, sobald die Zahlen da sind.
_ = RefreshAsync();
}
[RelayCommand]
private async Task RefreshAsync()
{
if (_host?.Usage is not { } usage) return;
try
{
var today = DateOnly.FromDateTime(DateTime.Now);
var daily = new List<DailyUsage>(Days);
for (var offset = Days - 1; offset >= 0; offset--)
daily.Add(await usage.GetDailyAsync(today.AddDays(-offset), "", CancellationToken.None));
var recent = await usage.GetRecentAsync(RecentRunLimit, CancellationToken.None);
var since = DateTime.Now.Date.AddDays(-(Days - 1));
var window = recent.Where(r => r.OccurredAt >= since).ToArray();
BuildChart(daily);
BuildBreakdown(window);
BuildTotals(daily, window);
}
catch (Exception ex)
{
StatusText = $"Verbrauchsdaten konnten nicht gelesen werden: {ex.Message}";
}
}
private void BuildChart(IReadOnlyList<DailyUsage> daily)
{
Days7.Clear();
// Ohne Höchstwert gäbe es keinen Maßstab; ohne die Untergrenze 1 eine Division
// durch null an dem Tag, an dem noch nichts gelaufen ist.
var max = Math.Max(1, daily.Max(d => d.TotalTokens));
foreach (var day in daily)
{
Days7.Add(new TokenDayItem
{
DayLabel = day.Date.ToString("ddd"),
Tokens = day.TotalTokens,
TokensLabel = day.TotalTokens >= 1000
? $"{day.TotalTokens / 1000d:0.#}k"
: day.TotalTokens.ToString("N0"),
// Mindestens ein Pixel, damit ein Tag mit wenigen Tokens nicht wie ein
// Tag ohne Läufe aussieht.
BarHeight = Math.Max(1, day.TotalTokens / (double)max * ChartHeight)
});
}
}
private void BuildBreakdown(IReadOnlyList<RunUsage> runs)
{
ByAgent.Clear();
var names = _host?.Instance.Agents
.ToDictionary(a => a.AgentId, a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
?? [];
var groups = runs
.GroupBy(r => (r.AgentId, r.Model))
.OrderByDescending(g => g.Sum(r => r.PromptTokens + r.CompletionTokens));
foreach (var group in groups)
{
var (agentId, model) = group.Key;
var tokens = group.Sum(r => r.PromptTokens + r.CompletionTokens);
var cost = group.Sum(r => r.CostUsd);
var incomplete = group.Any(r => !r.CostIsKnown);
ByAgent.Add(new TokenAgentItem
{
AgentName = names.TryGetValue(agentId, out var name) ? name : agentId,
Model = model,
TokensLabel = tokens.ToString("N0"),
CostLabel = FormatCost(cost, incomplete),
Runs = group.Count()
});
}
}
private void BuildTotals(IReadOnlyList<DailyUsage> daily, IReadOnlyList<RunUsage> runs)
{
var tokens = daily.Sum(d => d.TotalTokens);
var cost = daily.Sum(d => d.CostUsd);
var incomplete = daily.Any(d => !d.CostIsComplete);
TotalTokens = tokens.ToString("N0");
TotalCost = FormatCost(cost, incomplete);
TotalRuns = daily.Sum(d => d.RunCount).ToString("N0");
IsEmpty = tokens == 0 && runs.Count == 0;
StatusText = IsEmpty ? LocalizationManager.Instance["desktop.usage.empty"] : "";
}
/// <summary>
/// Kosten in Euro, wie in der Statusleiste — mit demselben Kurs aus
/// <see cref="OpenRouterStatusService.UsdToEur"/>. Das Zeichen ⚠ steht für Läufe,
/// deren Modellpreise nicht vorlagen: Die Summe ist dann zu niedrig, und das soll man
/// ihr ansehen.
/// </summary>
private static string FormatCost(decimal usd, bool incomplete)
{
var eur = (double)usd * OpenRouterStatusService.UsdToEur;
return incomplete ? $"{eur:F2} € ⚠" : $"{eur:F2} €";
}
}
@@ -1,221 +0,0 @@
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();
}
}
@@ -1,22 +0,0 @@
using ClawdDotNet.Desktop.Services;
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>).
///
/// <para><see cref="L"/> macht die Sprachpakete in jeder Ansicht als
/// <c>{Binding L[schluessel]}</c> erreichbar.</para>
/// </summary>
public abstract class ViewModelBase : ObservableObject
{
public LocalizationManager L => LocalizationManager.Instance;
}
@@ -1,51 +0,0 @@
<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="{Binding L[desktop.dialog.addAgent]}"
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="{Binding L[common.cancel]}" Command="{Binding CancelCommand}" />
<Button Content="{Binding L[common.create]}" 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="{Binding L[desktop.dialog.agentId]}" FontWeight="Bold" />
<TextBox Text="{Binding AgentId}" PlaceholderText="z.B. dev-assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.agentDisplayName]}" FontWeight="Bold" />
<TextBox Text="{Binding DisplayName}" PlaceholderText="z.B. Dev Assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.defaultModel]}" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableModels}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.roleDescription]}" FontWeight="Bold" />
<TextBox Text="{Binding Description}" PlaceholderText="Role description..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -1,21 +0,0 @@
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();
}
}
}
@@ -1,80 +0,0 @@
<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="{Binding L[desktop.dialog.addJobTitle]}"
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="{Binding L[common.cancel]}" Command="{Binding CancelCommand}" />
<Button Content="{Binding L[common.save]}" 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="{Binding L[desktop.tasks.thAgent]}" 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="{Binding L[desktop.dialog.jobType]}" FontWeight="Bold" />
<RadioButton Content="{Binding L[desktop.dialog.agentWakeup]}" IsChecked="{Binding IsAgentWakeup}" />
<RadioButton Content="{Binding L[desktop.dialog.toolJob]}" IsChecked="{Binding IsToolJob}" />
</StackPanel>
<!-- Tool Job Details -->
<StackPanel Spacing="8" IsVisible="{Binding IsToolJob}" Margin="12,4,0,0">
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.agents.thTool]}" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableTools}" SelectedItem="{Binding SelectedToolName}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.jobTypeId]}" FontWeight="Bold" />
<TextBox Text="{Binding JobTypeId}" PlaceholderText="z.B. PollMail" />
</StackPanel>
</StackPanel>
<!-- Agent Wakeup Details -->
<StackPanel Spacing="4" IsVisible="{Binding IsAgentWakeup}">
<TextBlock Text="{Binding L[desktop.dialog.taskMessage]}" FontWeight="Bold" />
<TextBox Text="{Binding TaskMessage}" PlaceholderText="Message to agent..." AcceptsReturn="True" Height="60" />
</StackPanel>
<!-- Cron & Options -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.cronExpression]}" FontWeight="Bold" />
<TextBox Text="{Binding CronExpression}" PlaceholderText="0 * * * *" />
<TextBlock Text="{Binding L[desktop.dialog.cronHint]}" Classes="caption" />
</StackPanel>
<CheckBox Content="{Binding L[desktop.dialog.runOnStart]}" IsChecked="{Binding RunOnStart}" Margin="0,4,0,0" />
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -1,21 +0,0 @@
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();
}
}
}
@@ -1,53 +0,0 @@
<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="{Binding L[desktop.dialog.addServiceTitle]}"
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="{Binding L[common.cancel]}" Command="{Binding CancelCommand}" />
<Button Content="{Binding L[common.add]}" 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="{Binding L[desktop.dialog.serviceName]}" 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="{Binding L[desktop.dialog.serviceType]}" 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="{Binding L[desktop.dialog.servicePort]}" FontWeight="Bold" />
<NumericUpDown Value="{Binding ServicePort}" Minimum="1" Maximum="65535" FormatString="0" />
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.serviceDesc]}" FontWeight="Bold" />
<TextBox Text="{Binding ServiceDescription}" PlaceholderText="Service description..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -1,21 +0,0 @@
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();
}
}
}
@@ -1,61 +0,0 @@
<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="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.AgentChatsPageView"
x:DataType="vm:AgentChatsPageViewModel">
<DockPanel MaxWidth="820" HorizontalAlignment="Left">
<!-- Wer mit wem -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10"
Margin="0,0,0,16" VerticalAlignment="Center">
<TextBlock Text="{Binding L[desktop.agentChats.agentA]}" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentA}" />
<TextBlock Text="{Binding L[desktop.agentChats.agentB]}" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="8,0,0,0" />
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentB}" />
</StackPanel>
<!-- Leerzustand: erklärt, warum hier noch nichts steht -->
<Border Classes="card" DockPanel.Dock="Top" VerticalAlignment="Top"
IsVisible="{Binding IsEmpty}">
<TextBlock Classes="muted" Text="{Binding EmptyText}" TextWrapping="Wrap" />
</Border>
<!-- Verlauf -->
<ScrollViewer VerticalScrollBarVisibility="Auto" IsVisible="{Binding !IsEmpty}">
<ItemsControl ItemsSource="{Binding Messages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AgentMessageItem">
<Border Classes="card" MaxWidth="560"
Classes.accent="{Binding !IsFromAgentA}"
HorizontalAlignment="{Binding !IsFromAgentA,
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock FontSize="12" FontWeight="SemiBold" Text="{Binding SenderName}" />
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
</DockPanel>
<SelectableTextBlock FontSize="13.5" TextWrapping="Wrap" Text="{Binding Text}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,8 +0,0 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class AgentChatsPageView : UserControl
{
public AgentChatsPageView() => InitializeComponent();
}
@@ -1,293 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="820"
x:Class="ClawdDotNet.Desktop.Views.AgentsPageView"
x:DataType="vm:AgentsPageViewModel">
<!--
Vier Felder wie in der WinForms-Vorlage: oben Agentenliste und Agenteneinstellungen,
unten Werkzeugliste und Werkzeugeinstellungen — aber mit ziehbaren Teilern statt
fester SplitterDistance und mit flachen Feldern statt PropertyGrid.
Die Teilerstellungen werden im Code-Behind gelesen und gespeichert: ColumnDefinition
liegt nicht im Sichtbarkeitsbaum und erbt deshalb keinen DataContext — eine Bindung
darauf wuerde stillschweigend nichts tun.
-->
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<Grid x:Name="OuterGrid" RowDefinitions="*,Auto,*">
<!-- ══════════════════ Oben: Agenten ══════════════════ -->
<Grid x:Name="TopGrid" Grid.Row="0" ColumnDefinitions="0.44*,Auto,0.56*">
<!-- Liste -->
<DockPanel Grid.Column="0" Margin="0,0,14,0">
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar" Command="{Binding AddAgentCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.addAgent]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RemoveAgentCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconMinus}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.removeAgent]}" />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding RunAgentNowCommand}"
IsEnabled="{Binding !IsRunning}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="{Binding L[desktop.agents.runNow]}" />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding EditIdentityCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.identity]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding EditSoulCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.soul]}" />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding OpenAgentFolderCommand}"
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.folder]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding SaveAgentsCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.save]}" />
</StackPanel>
</Button>
</WrapPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.4*,1.6*,2*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.agents.thDisplayName]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.agents.thModel]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.agents.thSummary]}" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.4*,1.6*,2*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
Text="{Binding DisplayName}" />
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter x:Name="TopSplitter" Grid.Column="1" Classes="vertical"
ResizeDirection="Columns" />
<!-- Einstellungen des gewählten Agenten -->
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" Padding="4,0,0,0">
<ContentControl Content="{Binding SelectedAgent}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<StackPanel Spacing="20" MaxWidth="640" HorizontalAlignment="Left">
<StackPanel Spacing="12">
<TextBlock Classes="kicker" Text="{Binding L[desktop.agents.kickerGeneralModel]}" />
<TextBlock Classes="h2" Text="{Binding DisplayName}" />
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.agentId]}" />
<TextBox Text="{Binding AgentId}" IsReadOnly="True" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.displayName]}" />
<TextBox Text="{Binding DisplayName}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.model]}" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}"
SelectedItem="{Binding Model}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.promptCaching]}" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}"
SelectedItem="{Binding PromptCaching}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.agents.roleDescription]}" />
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="54"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.agents.systemPrompt]}" />
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="70"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
<Border Classes="hr" />
<StackPanel Spacing="12">
<TextBlock Classes="kicker" Text="{Binding L[desktop.agents.kickerLimits]}" />
<TextBlock Classes="h2" Text="{Binding L[desktop.agents.limitsTitle]}" />
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.maxSteps]}" />
<TextBox Text="{Binding MaxSteps}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.timeoutSeconds]}" />
<TextBox Text="{Binding TimeoutSeconds}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.maxContextTokens]}" />
<TextBox Text="{Binding MaxContextTokens}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.maxToolResultChars]}" />
<TextBox Text="{Binding MaxToolResultChars}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.dailyCost]}" />
<TextBox Text="{Binding DailyCostUsd}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.agents.dailyTokens]}" />
<TextBox Text="{Binding DailyTokens}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.agents.whenExhausted]}" />
<ComboBox HorizontalAlignment="Stretch"
ItemsSource="{Binding WhenExhaustedOptions}"
SelectedItem="{Binding WhenExhausted}" />
<TextBlock Classes="caption" TextWrapping="Wrap"
Text="{Binding L[desktop.agents.whenExhaustedHint]}" />
</StackPanel>
</StackPanel>
</StackPanel>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</ScrollViewer>
</Grid>
<GridSplitter x:Name="VerticalSplitter" Grid.Row="1" Classes="horizontal"
ResizeDirection="Rows" Margin="0,8" />
<!-- ══════════════════ Unten: Werkzeuge ══════════════════ -->
<Grid x:Name="BottomGrid" Grid.Row="2" ColumnDefinitions="0.44*,Auto,0.56*">
<DockPanel Grid.Column="0" Margin="0,0,14,0">
<TextBlock DockPanel.Dock="Top" Classes="kicker" Margin="0,4,0,8"
Text="{Binding AllowedToolsHeader}" />
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="36,1.4*,2.4*">
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.agents.thTool]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.agents.thDescription]}" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding SelectedAgent.AvailableTools}"
SelectedItem="{Binding SelectedTool}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:ToolItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="36,1.4*,2.4*">
<CheckBox Grid.Column="0" IsChecked="{Binding IsEnabled}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="td" FontWeight="SemiBold"
Text="{Binding Name}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter x:Name="BottomSplitter" Grid.Column="1" Classes="vertical"
ResizeDirection="Columns" />
<DockPanel Grid.Column="2" Margin="4,0,0,0">
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,4,0,10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.agents.toolSettingsKicker]}" />
<DockPanel>
<TextBlock Classes="h2" VerticalAlignment="Center"
Text="{Binding SelectedTool.Name}" />
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar primary"
Command="{Binding SaveToolSettingsCommand}"
IsEnabled="{Binding ToolSettings, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.agents.apply]}" />
</StackPanel>
</Button>
</DockPanel>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ContentControl Content="{Binding ToolSettings}" />
</ScrollViewer>
</DockPanel>
</Grid>
</Grid>
</DockPanel>
</UserControl>
@@ -1,101 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AgentsPageView : UserControl
{
private AgentsPageViewModel? _viewModel;
public AgentsPageView()
{
InitializeComponent();
// Ein Zug am Teiler ist erst zu Ende, wenn die Maus losgelassen wird. Waehrend
// des Ziehens zu speichern hiesse, fuer eine Bewegung ein paar Dutzend Mal die
// Einstellungsdatei zu schreiben.
TopSplitter.DragCompleted += (_, _) => SaveSplits();
BottomSplitter.DragCompleted += (_, _) => SaveSplits();
VerticalSplitter.DragCompleted += (_, _) => SaveSplits();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is not AgentsPageViewModel vm) return;
_viewModel = vm;
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
vm.RequestTextEditorDialog += OnRequestTextEditorDialog;
ApplySavedSplits(vm);
}
/// <summary>
/// Setzt die gespeicherten Teilerstellungen.
///
/// <para>Von Hand und nicht per Bindung: <see cref="ColumnDefinition"/> und
/// <see cref="RowDefinition"/> liegen nicht im Sichtbarkeitsbaum und erben deshalb
/// keinen DataContext. Eine Bindung auf ihre Breite bliebe ohne Wirkung — und zwar
/// ohne Fehlermeldung.</para>
/// </summary>
private void ApplySavedSplits(AgentsPageViewModel vm)
{
SetRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2], vm.SplitTopList);
SetRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2], vm.SplitBottomList);
OuterGrid.RowDefinitions[0].Height = new GridLength(vm.SplitVertical, GridUnitType.Star);
OuterGrid.RowDefinitions[2].Height = new GridLength(1 - vm.SplitVertical, GridUnitType.Star);
}
private static void SetRatio(ColumnDefinition first, ColumnDefinition second, double ratio)
{
first.Width = new GridLength(ratio, GridUnitType.Star);
second.Width = new GridLength(1 - ratio, GridUnitType.Star);
}
private void SaveSplits()
{
if (_viewModel is null) return;
_viewModel.SaveSplits(
RowRatio(OuterGrid.RowDefinitions[0], OuterGrid.RowDefinitions[2]),
ColumnRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2]),
ColumnRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2]));
}
/// <summary>
/// Das Verhaeltnis wird aus den tatsaechlichen Pixelbreiten gebildet, nicht aus den
/// Sternwerten: Der GridSplitter setzt beim Ziehen mitunter absolute Breiten, und
/// dann stuenden in den Sternwerten noch die Zahlen von vor dem Zug.
/// </summary>
private static double ColumnRatio(ColumnDefinition first, ColumnDefinition second)
=> Ratio(first.ActualWidth, second.ActualWidth);
private static double RowRatio(RowDefinition first, RowDefinition second)
=> Ratio(first.ActualHeight, second.ActualHeight);
private static double Ratio(double first, double second)
{
var total = first + second;
return total > 0 ? first / total : 0.5;
}
private async Task<AddAgentResultData?> OnRequestAddAgentDialog(AddAgentViewModel vm)
{
if (TopLevel.GetTopLevel(this) is not Window window) return null;
var dialog = new AddAgentWindow { DataContext = vm };
await dialog.ShowDialog(window);
return vm.Result;
}
private async Task<string?> OnRequestTextEditorDialog(string title, string text)
{
if (TopLevel.GetTopLevel(this) is not Window window) return null;
return await TextEditorWindow.ShowAsync(window, title, text);
}
}
@@ -1,156 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.BackupPageView"
x:DataType="vm:BackupPageViewModel">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="820" HorizontalAlignment="Left">
<!-- ══════════════════ Manuell ══════════════════ -->
<StackPanel Spacing="12">
<TextBlock Classes="kicker" Text="{Binding L[desktop.backup.kickerManual]}" />
<TextBlock Classes="h2" Text="{Binding L[desktop.backup.titleCreate]}" />
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Bottom">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.backup.targetFolder]}" />
<TextBox Text="{Binding TargetFolder}"
PlaceholderText="{Binding L[desktop.backup.targetFolderPlaceholder]}" />
</StackPanel>
<Button Grid.Column="1" Content="{Binding L[desktop.backup.browse]}" Click="OnBrowseTargetFolder"
Margin="8,0,0,0" VerticalAlignment="Bottom" />
</Grid>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="{Binding L[desktop.backup.credentials]}" />
<StackPanel Orientation="Horizontal" Spacing="18">
<RadioButton GroupName="secrets" Content="{Binding L[desktop.backup.credentialsExclude]}"
IsChecked="{Binding SecretsExclude}" />
<RadioButton GroupName="secrets" Content="{Binding L[desktop.backup.credentialsProtect]}"
IsChecked="{Binding SecretsPassphrase}" />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" Margin="24,0,0,0" IsVisible="{Binding SecretsPassphrase}">
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Width="320"
HorizontalAlignment="Left" PlaceholderText="{Binding L[desktop.backup.passphrase]}" />
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="•" Width="320"
HorizontalAlignment="Left" PlaceholderText="{Binding L[desktop.backup.passphraseRepeat]}" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="{Binding L[desktop.backup.scope]}" />
<StackPanel Orientation="Horizontal" Spacing="18">
<CheckBox Content="{Binding L[desktop.backup.scopeChat]}" IsChecked="{Binding IncludeChatHistory}" />
<CheckBox Content="{Binding L[desktop.backup.scopeLogs]}" IsChecked="{Binding IncludeLogs}" />
</StackPanel>
</StackPanel>
<Button Classes="primary" HorizontalAlignment="Left" Margin="0,4,0,0"
Command="{Binding CreateBackupCommand}" IsEnabled="{Binding !IsBusy}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="{Binding L[desktop.backup.createNow]}" />
</StackPanel>
</Button>
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Automatik ══════════════════ -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.backup.kickerAuto]}" />
<TextBlock Classes="h2" Text="{Binding L[desktop.backup.titleAuto]}" />
<CheckBox Content="{Binding L[desktop.backup.autoDaily]}" IsChecked="{Binding AutoBackupEnabled}" />
<StackPanel Orientation="Horizontal" Spacing="24" Margin="24,0,0,0"
IsEnabled="{Binding AutoBackupEnabled}">
<StackPanel Width="100">
<TextBlock Classes="label" Text="{Binding L[desktop.backup.time]}" />
<TextBox Text="{Binding AutoBackupTime}" PlaceholderText="03:00" />
</StackPanel>
<StackPanel Width="160">
<TextBlock Classes="label" Text="{Binding L[desktop.backup.keep]}" />
<TextBox Text="{Binding KeepCount}" />
</StackPanel>
</StackPanel>
<TextBlock Classes="caption" Margin="24,0,0,0" TextWrapping="Wrap"
Text="{Binding L[desktop.backup.autoHint]}" />
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Bestand ══════════════════ -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.backup.kickerExisting]}" />
<DockPanel>
<TextBlock Classes="h2" Text="{Binding L[desktop.backup.titleExisting]}" VerticalAlignment="Center" />
<!-- Mindestabstand zur Ueberschrift links. Vorher stand hier Margin="0" und
der Zwischenraum entstand nur, wenn das Fenster breit genug war; bei
1600 Punkten reichte er gerade nicht, und "Vorhandene Sicherungen"
beruehrte die Schaltflaeche "Aktualisieren". -->
<WrapPanel DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
Margin="16,0,0,0">
<Button Classes="toolbar" Content="{Binding L[desktop.backup.refresh]}"
Command="{Binding RefreshBackupListCommand}" />
<Button Classes="toolbar" Content="{Binding L[desktop.backup.openFolder]}"
Command="{Binding ShowSelectedInFolderCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="toolbar" Content="{Binding L[desktop.backup.delete]}"
Command="{Binding DeleteSelectedCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="toolbar primary" Content="{Binding L[desktop.backup.restore]}"
Command="{Binding RestoreSelectedCommand}"
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
</WrapPanel>
</DockPanel>
<Border Classes="thead">
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.backup.thFilename]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.backup.thCreatedAt]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.backup.thInstance]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.backup.thSize]}" />
<TextBlock Grid.Column="4" Classes="th" Text="{Binding L[desktop.backup.thSecrets]}" />
</Grid>
</Border>
<ListBox Classes="table" MaxHeight="300" Margin="0,-10,0,0"
ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:BackupItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
<TextBlock Grid.Column="0" Classes="td" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Classes="td"
Text="{Binding LastWriteTime, StringFormat='{}{0:dd.MM.yyyy HH:mm}'}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding InstanceName}" />
<TextBlock Grid.Column="3" Classes="td" Text="{Binding SizeFormatted}" />
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding SecretsInfo}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,56 +0,0 @@
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);
}
}
}
@@ -1,94 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.ChatPageView"
x:DataType="vm:ChatPageViewModel">
<DockPanel MaxWidth="960" HorizontalAlignment="Left">
<!-- Agentenauswahl und Verlaufsbefehle -->
<StackPanel DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<TextBlock Text="{Binding L[desktop.chat.agent]}" FontWeight="SemiBold" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}"
Width="220">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding ClearHistoryCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.chat.clearHistory]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconStop}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="{Binding L[desktop.chat.abort]}" />
</StackPanel>
</Button>
</StackPanel>
<!-- Statuszeile des Bereichs -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,8,0,0"
Text="{Binding StatusText}" />
<!-- Eingabe -->
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<TextBox Grid.Column="0" Text="{Binding InputPrompt}"
PlaceholderText="{Binding L[desktop.chat.inputPlaceholder]}"
AcceptsReturn="True" Height="60" TextWrapping="Wrap"
KeyDown="OnInputKeyDown" />
<Button Grid.Column="1" Classes="primary" Content="{Binding L[desktop.chat.send]}" Width="100"
Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
VerticalAlignment="Stretch" Margin="8,0,0,0" />
</Grid>
<!-- Verlauf -->
<ScrollViewer x:Name="ChatScroller" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding ChatEntries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="12" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ChatEntryItemViewModel">
<!-- Eigene Nachrichten rechts und in Akzent hinterlegt, Antworten links
als schlichter Kasten — wie im Entwurf. -->
<Border Classes="card" MaxWidth="660"
Classes.accent="{Binding IsUser}"
HorizontalAlignment="{Binding IsUser,
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
<StackPanel Spacing="4">
<DockPanel>
<TextBlock Text="{Binding Sender}" FontWeight="SemiBold" FontSize="12" />
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
Margin="8,0,0,0"
Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" />
</DockPanel>
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13.5" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,25 +0,0 @@
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;
}
}
}
}
@@ -1,128 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.InfoPageView"
x:DataType="vm:InfoPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<!-- Etwas Luft nach aussen: Die Eckmarken des BlueprintFrame ragen ueber den
Rahmen hinaus und wuerden sonst am Rand des Bereichs abgeschnitten. -->
<StackPanel Spacing="26" Margin="8" MaxWidth="820" HorizontalAlignment="Left">
<!-- Die einzige Karte des Entwurfs mit Eckmarken -->
<ctrl:BlueprintFrame MaxWidth="420" HorizontalAlignment="Left">
<StackPanel Spacing="6">
<TextBlock Classes="h2" Text="ClawdDotNet" />
<TextBlock Classes="muted" FontSize="13">
<Run Text="Version" /><Run Text=" " /><Run Text="{Binding AppVersion}" /><Run Text=" · Avalonia UI · net10.0" />
</TextBlock>
</StackPanel>
</ctrl:BlueprintFrame>
<!-- Anwendung -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.info.kickerApp]}" />
<TextBlock Classes="h2" Text="{Binding L[desktop.info.build]}" />
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto" >
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.version]}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.buildSummary]}"
Margin="0,6,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}"
TextWrapping="Wrap" Margin="0,6,0,0" />
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- Instanz -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.info.kickerInstance]}" />
<DockPanel>
<TextBlock Classes="h2" Text="{Binding L[desktop.info.currentInstance]}" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
Command="{Binding OpenInstanceFolderCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.info.openFolder]}" />
</StackPanel>
</Button>
</DockPanel>
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.name]}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.id]}" Margin="0,6,0,0" />
<TextBlock Grid.Row="1" Grid.Column="1" Classes="mono" Text="{Binding InstanceId}"
Margin="0,6,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.path]}" Margin="0,6,0,0" />
<SelectableTextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}"
TextWrapping="Wrap" Margin="0,6,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.logDir]}" Margin="0,6,0,0" />
<SelectableTextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}"
TextWrapping="Wrap" Margin="0,6,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Classes="label" Text="{Binding L[desktop.info.openRouter]}" Margin="0,6,0,0" />
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,6,0,0" />
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- Baugruppen -->
<StackPanel Spacing="10">
<TextBlock Classes="kicker" Text="{Binding L[desktop.info.kickerModules]}" />
<TextBlock Classes="h2" Text="{Binding L[desktop.info.assemblies]}" />
<!-- Feste Breiten statt Sternanteilen fuer die drei schmalen Spalten.
Sternbreiten teilen den Platz erst auf, NACHDEM die Mindestbreite jedes
Inhalts gedeckt ist. Weil jede Zeile ein eigenes Grid ist, hat ein langer
Aenderungstext seiner Zeile - und nur ihr - den Platz der anderen drei
Spalten weggenommen: Die BUILD-Zahl rutschte unter MODUL, BUILD-DATUM und
AENDERUNGEN stiessen ohne Trennung aneinander. Feste Breiten koennen nicht
zusammengedrueckt werden, also stehen Kopf und alle Zeilen buendig, ganz
gleich, was in der letzten Spalte steht.
Die letzte Spalte bleibt "*": Sie ist die letzte, ein Ueberlauf dort
verschiebt nichts mehr, und ClipToBounds haelt ihn innerhalb der Tabelle.
Der linke Aussenabstand sorgt dafuer, dass Datum und Text auch im
Grenzfall nicht aneinanderstossen. -->
<Border Classes="thead">
<Grid ColumnDefinitions="170,60,150,*" ClipToBounds="True">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.info.thModule]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.info.thBuild]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.info.thBuildDate]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.info.thChanges]}" Margin="10,0,0,0" />
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Assemblies}" Margin="0,-10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:AssemblyInfoItem">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="170,60,150,*" ClipToBounds="True">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding ShortName}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding BuildNumber}" />
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding BuildDate}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding Changes}" Margin="10,0,0,0" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class InfoPageView : UserControl
{
public InfoPageView()
{
InitializeComponent();
}
}
@@ -1,123 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="560"
x:Class="ClawdDotNet.Desktop.Views.InstancePickerWindow"
x:DataType="vm:InstancePickerViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet – Instanz"
Width="640" Height="560"
MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterScreen">
<!-- Rahmen mit Eckmarken wie im Entwurf; 12 Punkte Abstand, weil die Marken nach
aussen ragen. -->
<ctrl:BlueprintFrame Margin="12" Padding="16">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,14">
<TextBlock Classes="h1" Text="{Binding Title}" />
<TextBlock Classes="muted" FontSize="13" Text="{Binding Hint}" TextWrapping="Wrap" />
<TextBlock Classes="caption" TextWrapping="Wrap"
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
</StackPanel>
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
<TextBlock DockPanel.Dock="Bottom" Margin="0,10,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource DangerBrush}"
Text="{Binding ErrorText}"
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Neue Instanz anlegen -->
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,14,0,0">
<Border Classes="hr" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding NewInstanceName}"
PlaceholderText="{Binding L[desktop.instancePicker.newInstancePlaceholder]}" />
<Button Grid.Column="1" Classes="primary" Margin="8,0,0,0"
Command="{Binding CreateCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.instancePicker.create]}" />
</StackPanel>
</Button>
</Grid>
</StackPanel>
<!-- Abschluss -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="{Binding L[common.cancel]}" Command="{Binding CancelCommand}" IsCancel="True" />
<Button Classes="primary" Content="{Binding ConfirmLabel}" IsDefault="True"
Command="{Binding StartCommand}" />
</StackPanel>
<!-- Liste -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="InstanceList" ItemsSource="{Binding Instances}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:InstanceChoiceViewModel">
<Border Classes="card" Padding="8,8">
<DockPanel>
<!-- Monogramm mit Statuspunkt -->
<Panel DockPanel.Dock="Left" Width="30" Height="30" Margin="0,0,10,0"
VerticalAlignment="Center">
<Border Width="26" Height="26" Background="{DynamicResource Accent600Brush}"
HorizontalAlignment="Left" VerticalAlignment="Top">
<TextBlock Classes="h2" FontSize="10" Text="{Binding Monogram}"
Foreground="{DynamicResource Neutral100Brush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
Fill="{DynamicResource SuccessBrush}"
IsVisible="{Binding IsReady}"
ToolTip.Tip="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).L[desktop.instancePicker.tooltipConfigured]}" />
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
Fill="{DynamicResource Neutral500Brush}"
IsVisible="{Binding !IsReady}"
ToolTip.Tip="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).L[desktop.instancePicker.tooltipMissing]}" />
</Panel>
<Button DockPanel.Dock="Right" Classes="toolbar" VerticalAlignment="Center"
Content="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ConfirmLabel}"
Command="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ActivateCommand}"
CommandParameter="{Binding}" />
<Border DockPanel.Dock="Right" Classes="tag ok" Margin="0,0,8,0"
IsVisible="{Binding IsCurrent}">
<TextBlock Text="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).L[desktop.instancePicker.active]}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Classes="h2" FontSize="13.5" Text="{Binding InstanceName}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="caption" Text="{Binding Summary}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</ctrl:BlueprintFrame>
</Window>
@@ -1,30 +0,0 @@
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 bestaetigt — wie in der WinForms-Fassung.
//
// Der Behandler haengt an der Liste und nicht mehr am Fenster: Am Fenster loeste
// auch ein Doppelklick ins Namensfeld die Auswahl aus, was seit dem Umbau
// deutlich leichter passiert, weil das Feld gross und mittig steht.
InstanceList.AddHandler(DoubleTappedEvent, OnRowDoubleTapped, handledEventsToo: false);
}
private void OnRowDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not InstancePickerViewModel vm) return;
// Getroffen wird irgendein Element in der Zeile; der Datenkontext ist derselbe.
if ((e.Source as Control)?.DataContext is not InstanceChoiceViewModel choice) return;
vm.ActivateCommand.Execute(choice);
}
}
@@ -1,45 +0,0 @@
<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="{Binding L[desktop.dialog.licenseTitle]}"
Width="520" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" Text="{Binding L[desktop.dialog.licenseKey]}" />
<!-- 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 PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
Text="{Binding Key}"
FontFamily="{DynamicResource ContentControlThemeFontFamily}" />
<StackPanel Spacing="4">
<TextBlock Classes="caption" Text="{Binding L[desktop.dialog.hardwareId]}" />
<SelectableTextBlock Classes="caption"
Text="{Binding HardwareId}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="{Binding L[common.exit]}" Command="{Binding CancelCommand}" />
<Button Content="{Binding L[desktop.dialog.verify]}" Classes="accent" IsDefault="True"
Command="{Binding ConfirmCommand}" />
</StackPanel>
</StackPanel>
</Window>
@@ -1,16 +0,0 @@
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();
}
}
@@ -1,96 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.LogPageView"
x:DataType="vm:LogPageViewModel">
<DockPanel>
<!-- Werkzeugleiste -->
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
<TextBlock Text="{Binding L[desktop.logs.module]}" FontSize="12.5" VerticalAlignment="Center" />
<ComboBox ItemsSource="{Binding Modules}" SelectedItem="{Binding SelectedModule}"
Width="180" MinHeight="30" Padding="8,3" />
<TextBlock Text="{Binding L[desktop.logs.level]}" FontSize="12.5" VerticalAlignment="Center" Margin="6,0,0,0" />
<ComboBox ItemsSource="{Binding Levels}" SelectedItem="{Binding SelectedLevel}"
Width="120" MinHeight="30" Padding="8,3" />
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding RefreshModulesCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.logs.refresh]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding ClearCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.logs.clear]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding OpenFolderCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.logs.openFolder]}" />
</StackPanel>
</Button>
<CheckBox Content="{Binding L[desktop.logs.follow]}" IsChecked="{Binding FollowTail}"
VerticalAlignment="Center" Margin="8,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.
-->
<Border Classes="console">
<ScrollViewer Name="Scroller" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Lines}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="svc:LogLine">
<!-- Nur Warnung und Fehler werden 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. -->
<SelectableTextBlock Classes="mono"
Classes.warn="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=WRN}"
Classes.err="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=ERR}"
Classes.ftl="{Binding Level,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=FTL}"
Text="{Binding Text}"
LineHeight="21"
TextWrapping="NoWrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</UserControl>
@@ -1,21 +0,0 @@
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();
};
}
}
@@ -1,231 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1440" d:DesignHeight="860"
x:Class="ClawdDotNet.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/app.ico"
Title="ClawdDotNet"
Width="1440" Height="860"
MinWidth="960" MinHeight="600">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<Grid ColumnDefinitions="Auto,*">
<!-- ══════════════════════════ Seitenleiste ══════════════════════════ -->
<Border Grid.Column="0" Classes="sidebar" Width="{Binding SidebarWidth}">
<DockPanel>
<!-- Marke und Einklapp-Schalter -->
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,0,1" Padding="14">
<DockPanel>
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="20" Height="20"
Foreground="{DynamicResource AccentBrush}"
DockPanel.Dock="Left" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right" Classes="flat"
Command="{Binding ToggleSidebarCommand}"
ToolTip.Tip="{Binding L[desktop.mainWindow.sidebarTooltip]}"
Padding="4" VerticalAlignment="Center">
<ctrl:StrokeIcon Width="14" Height="14"
Data="{Binding IsSidebarCollapsed,
Converter={x:Static conv:SidebarChevronConverter.Instance}}" />
</Button>
<TextBlock Classes="h2" Text="ClawdDotNet" Margin="8,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding IsSidebarExpanded}" />
</DockPanel>
</Border>
<!-- Fußzeile: Version -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="16,10"
Text="{Binding VersionText}"
IsVisible="{Binding IsSidebarExpanded}" />
<!-- Instanz-Schalter: der einzige Knopf mit Akzentrahmen, weil er als
einziger die ganze Anwendung umschaltet.
Ausgegraut, sobald der Instanzmanager diese Instanz fuehrt (#528, E-M2):
Gewechselt wird dann dort, und er holt das Fenster der gewuenschten Instanz
nach vorn, statt eine laufende zu beenden. -->
<Button DockPanel.Dock="Top" Classes="instance" Margin="12,12,12,0"
Command="{Binding OpenInstanceManagerCommand}"
IsEnabled="{Binding CanSwitchInstance}"
ToolTip.Tip="{Binding L[desktop.mainWindow.switchInstance]}">
<DockPanel>
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
DockPanel.Dock="Left" VerticalAlignment="Center" />
<ctrl:StrokeIcon Data="{StaticResource IconChevronDown}" Width="14" Height="14"
DockPanel.Dock="Right" VerticalAlignment="Center"
IsVisible="{Binding IsSidebarExpanded}" />
<StackPanel Margin="8,0,0,0" IsVisible="{Binding IsSidebarExpanded}">
<TextBlock Text="{Binding L[desktop.mainWindow.instance]}" FontSize="9" LetterSpacing="1"
Foreground="{DynamicResource AccentBrush}" />
<TextBlock Classes="h2" FontSize="13" Text="{Binding InstanceName}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Button>
<!-- Navigation -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="10,14">
<ItemsControl ItemsSource="{Binding NavGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="18" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NavGroupViewModel">
<StackPanel Spacing="2">
<TextBlock Classes="navgroup" Text="{Binding Title}"
IsVisible="{Binding IsTitleVisible}" />
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NavItemViewModel">
<Button Classes="nav"
Classes.selected="{Binding IsSelected}"
Classes.collapsed="{Binding IsCollapsed}"
Command="{Binding SelectCommand}"
ToolTip.Tip="{Binding Title}">
<StackPanel Orientation="Horizontal" Spacing="10">
<ctrl:StrokeIcon Width="17" Height="17"
VerticalAlignment="Center"
Data="{Binding IconKey,
Converter={x:Static conv:IconConverter.Instance}}" />
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"
IsVisible="{Binding !IsCollapsed}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- ══════════════════════════ Hauptbereich ══════════════════════════ -->
<DockPanel Grid.Column="1">
<!-- Kopfzeile -->
<Border DockPanel.Dock="Top" Classes="topbar">
<DockPanel>
<TextBlock Classes="h1" Text="{Binding PageTitle}" VerticalAlignment="Center" />
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="10"
HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Classes="caption" Text="{Binding ThemeLabel}" VerticalAlignment="Center" />
<Button Classes="icon" Command="{Binding ToggleThemeCommand}"
ToolTip.Tip="{Binding L[desktop.mainWindow.themeToggle]}">
<ctrl:StrokeIcon Width="16" Height="16"
Data="{Binding ThemeIconKey,
Converter={x:Static conv:IconConverter.Instance}}" />
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- Benachrichtigungsleiste: nimmt keinen Platz, solange nichts anliegt -->
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding Notifications}"
IsVisible="{Binding HasNotifications}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:NotificationViewModel">
<Panel>
<!-- Zwei Grundflächen statt eines umgerechneten Pinsels: So bleiben die
Farben an DynamicResource gebunden und wechseln mit dem Thema mit. -->
<Border Background="{DynamicResource DangerBarBrush}"
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="3,0,0,0"
IsVisible="{Binding IsError}" />
<Border Background="{DynamicResource WarningSoftBrush}"
BorderBrush="{DynamicResource WarningBrush}" BorderThickness="3,0,0,0"
IsVisible="{Binding IsWarning}" />
<DockPanel Margin="24,8">
<ctrl:StrokeIcon Data="{StaticResource IconAlert}" Width="15" Height="15"
DockPanel.Dock="Left" VerticalAlignment="Center"
Margin="0,0,10,0" />
<Button DockPanel.Dock="Right" Classes="flat" Padding="2"
Command="{Binding DismissCommand}" ToolTip.Tip="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).L[desktop.mainWindow.dismiss]}"
VerticalAlignment="Center">
<ctrl:StrokeIcon Data="{StaticResource IconClose}" Width="13" Height="13" />
</Button>
<TextBlock FontSize="12.5" VerticalAlignment="Center" TextWrapping="Wrap">
<Run Text="{Binding InstanceName}" FontWeight="SemiBold" /><Run Text=": " /><Run Text="{Binding Text}" />
</TextBlock>
</DockPanel>
</Panel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Statusleiste -->
<Border DockPanel.Dock="Bottom" Classes="statusbar">
<DockPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="caption" Text="{Binding InstanceStatusLabel}" />
<TextBlock Classes="caption" Text="·" />
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{DynamicResource SuccessBrush}"
IsVisible="{Binding IsApiReachable}" />
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{DynamicResource DangerBrush}"
IsVisible="{Binding !IsApiReachable}" />
<TextBlock Classes="caption" Text="{Binding ApiStatusDisplay}" />
</StackPanel>
<TextBlock Classes="caption" Text="·"
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="caption" Text="{Binding UsageText}"
ToolTip.Tip="{Binding UsageTooltip}"
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Der Trenner gehoert zum Abschnitt dahinter: Ohne Guthabenangabe stuende
sonst ein Punkt am Ende der Zeile, der nichts mehr trennt. -->
<TextBlock Classes="caption" Text="·"
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="caption" Text="{Binding CreditsText}"
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock DockPanel.Dock="Right" Classes="caption" HorizontalAlignment="Right"
Text="{Binding ThemeLabel}" />
</DockPanel>
</Border>
<!-- Inhalt -->
<ContentControl Content="{Binding SelectedPage}" Margin="20" />
</DockPanel>
</Grid>
</Window>
@@ -1,168 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class MainWindow : Window
{
private bool _confirmedClose;
public MainWindow()
{
InitializeComponent();
Closing += OnClosing;
}
/// <summary>
/// Schließt ohne Rückfrage — für eine Beendigung, die nicht von diesem Fenster ausgeht.
///
/// <para><b>Warum das nötig ist.</b> Der Bestätigungsdialog schützt vor dem
/// versehentlichen Klick eines Menschen. Bittet dagegen der Instanzmanager über den
/// Socket, steht niemand davor: Der Dialog bliebe offen, die zehn Sekunden liefen ab,
/// und niemand klickte. Der Manager wartete seine Frist ab und schösse den Prozess
/// dann ab — der Dialog, der ein hartes Ende verhindern soll, hätte es garantiert.</para>
/// </summary>
public void BeendenOhneRueckfrage()
{
_confirmedClose = true;
Close();
}
private async void OnClosing(object? sender, WindowClosingEventArgs e)
{
if (_confirmedClose) return;
// Schließen zunächst abbrechen, um Bestätigungsdialog anzuzeigen
e.Cancel = true;
var confirmed = await ShowExitConfirmationAsync(this);
if (confirmed)
{
_confirmedClose = true;
Close();
}
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
// Der Instanzwechsel haengt nicht mehr hier, sondern in App: Er braucht den
// Aufbau und die Lebensdauer der Anwendung, und beides kennt das Fenster nicht.
// Vorher wurde hier ein InstancePickerWindow ganz ohne DataContext geoeffnet —
// ein leerer Dialog, dessen Auswahl niemand ausgewertet hat.
if (DataContext is MainWindowViewModel vm)
vm.RequestExit += Close;
}
internal static async Task<bool> ShowExitConfirmationAsync(Window? owner)
{
var completion = new TaskCompletionSource<bool>();
var remainingSeconds = 10;
var cancelBtn = new Button
{
Content = "Abbrechen (Instanz läuft weiter)",
IsCancel = true
};
var exitBtn = new Button
{
Content = $"Instanz beenden ({remainingSeconds}s)",
IsEnabled = false
};
exitBtn.Classes.Add("danger");
var countdownText = new TextBlock
{
Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…",
Classes = { "caption" }
};
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
timer.Tick += (_, _) =>
{
remainingSeconds--;
if (remainingSeconds > 0)
{
countdownText.Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…";
exitBtn.Content = $"Instanz beenden ({remainingSeconds}s)";
}
else
{
timer.Stop();
exitBtn.IsEnabled = true;
exitBtn.Content = "Instanz jetzt endgültig beenden";
countdownText.Text = "Sperre aufgehoben — Bestätigung jetzt möglich.";
}
};
var window = new Window
{
Title = "ClawdDotNet – Instanz beenden",
Width = 500,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Icon = owner?.Icon,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = "Achtung: Produktivinstanz beenden",
Classes = { "h1" }
},
new TextBlock
{
Text = "ClawdDotNet-Instanzen sind auf dauerhaften Dauerbetrieb ausgelegt. "
+ "Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap
},
countdownText,
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Children = { cancelBtn, exitBtn }
}
}
}
};
cancelBtn.Click += (_, _) =>
{
timer.Stop();
completion.TrySetResult(false);
window.Close();
};
exitBtn.Click += (_, _) =>
{
timer.Stop();
completion.TrySetResult(true);
window.Close();
};
window.Closed += (_, _) =>
{
timer.Stop();
completion.TrySetResult(false);
};
timer.Start();
if (owner is not null)
await window.ShowDialog(owner);
else
window.Show();
return await completion.Task;
}
}
@@ -1,66 +0,0 @@
<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="{Binding L[desktop.dialog.restoreTitle]}"
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="{Binding L[common.cancel]}" Command="{Binding CancelCommand}" />
<Button Content="{Binding L[desktop.backup.restore]}" 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="{Binding L[desktop.dialog.backupInfo]}" Classes="heading" />
<TextBlock Text="{Binding InstanceInfoText}" />
<TextBlock Text="{Binding CreatedAtText}" />
<TextBlock Text="{Binding FileCountText}" />
<TextBlock Text="{Binding SecretsText}" />
</StackPanel>
</Border>
<!-- Zielordner -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding L[desktop.dialog.targetDir]}" FontWeight="Bold" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Text="{Binding TargetDirectory}" Grid.Column="0" />
<Button Content="{Binding L[desktop.dialog.chooseFolder]}" Click="OnBrowseFolder" Grid.Column="1" Margin="8,0,0,0" />
</Grid>
<TextBlock Text="{Binding L[desktop.dialog.restoreHint]}"
Classes="caption" TextWrapping="Wrap" />
</StackPanel>
<!-- Passphrase -->
<StackPanel Spacing="4" IsVisible="{Binding NeedsPassphrase}">
<TextBlock Text="{Binding L[desktop.dialog.passphrase]}" FontWeight="Bold" />
<TextBox Text="{Binding Passphrase}" PasswordChar="*" />
<TextBlock Text="{Binding L[desktop.dialog.passphraseProtected]}" Classes="caption" />
</StackPanel>
<!-- Überschreiben -->
<CheckBox Content="{Binding L[desktop.dialog.overwriteExisting]}" IsChecked="{Binding Overwrite}" Margin="0,8,0,0" />
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -1,42 +0,0 @@
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;
}
}
}
@@ -1,155 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.SettingsPageView"
x:DataType="vm:SettingsPageViewModel">
<DockPanel>
<!-- Aktionen unten: Der Entwurf setzt flache Felder ohne Karten, damit bleibt am
unteren Rand nur die Zeile mit Verwerfen und Speichern. -->
<Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1,0,0" Padding="0,12,0,0" Margin="0,16,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="{Binding L[desktop.settings.discard]}" Command="{Binding LoadFromHostCommand}" />
<Button Classes="primary" Command="{Binding SaveSettingsCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.settings.save]}" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="26" MaxWidth="680" HorizontalAlignment="Left">
<!-- ══════════════════ Instanz ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="{Binding L[desktop.settings.instance]}" />
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.instanceName]}" />
<TextBox Text="{Binding InstanceName}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.apiKey]}" />
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<CheckBox IsChecked="{Binding AutostartByManager}"
Content="{Binding L[desktop.settings.autostart]}" />
<TextBlock Classes="caption" TextWrapping="Wrap" Margin="0,4,0,0"
Text="{Binding L[desktop.settings.autostartHint]}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.logDir]}" />
<TextBox Text="{Binding LogDirectory}" />
<TextBlock Classes="caption" TextWrapping="Wrap" Margin="0,4,0,0"
Text="{Binding L[desktop.settings.logDirHint]}" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.dailyCost]}" />
<TextBox Text="{Binding InstanceDailyCostUsd}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.dailyTokens]}" />
<TextBox Text="{Binding InstanceDailyTokens}" />
</StackPanel>
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Allgemein ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="{Binding L[desktop.settings.general]}" />
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.language]}" />
<ComboBox ItemsSource="{Binding AvailableLanguages}" SelectedItem="{Binding SelectedLanguageOption}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.instancesDir]}" />
<TextBox Text="{Binding InstancesDirectory}" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.minLogLevel]}" />
<ComboBox ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.openRouterUrl]}" />
<TextBox Text="{Binding OpenRouterBaseUrl}" />
</StackPanel>
</Grid>
</StackPanel>
<Border Classes="hr" />
<!-- ══════════════════ Deploymentcenter ══════════════════ -->
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="{Binding L[desktop.settings.dcAndLicense]}" />
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.dcUrl]}" />
<TextBox Text="{Binding DeploymentcenterUrl}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.dcToken]}" />
<TextBox Text="{Binding DeploymentcenterToken}" PasswordChar="•" />
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.updateChannel]}" />
<ComboBox ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.settings.dcEnv]}" />
<ComboBox ItemsSource="{Binding Environments}"
SelectedItem="{Binding DeploymentcenterEnvironment}"
HorizontalAlignment="Stretch" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.settings.licenseKey]}" />
<TextBox Text="{Binding LicenseKey}" PasswordChar="•" />
</StackPanel>
<StackPanel Spacing="10" Margin="0,4,0,0">
<CheckBox Content="{Binding L[desktop.settings.errorReporting]}"
IsChecked="{Binding ErrorReportingEnabled}" />
<CheckBox Content="{Binding L[desktop.settings.updateCheck]}"
IsChecked="{Binding UpdateCheckEnabled}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class SettingsPageView : UserControl
{
public SettingsPageView()
{
InitializeComponent();
}
}
@@ -1,257 +0,0 @@
<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:models="using:ClawdDotNet.App.Models"
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
x:Class="ClawdDotNet.Desktop.Views.TasksPageView"
x:DataType="vm:TasksPageViewModel">
<DockPanel>
<!-- Segmentleiste statt Registerkarten. Die Knöpfe überlappen sich um einen
Bildpunkt, damit zwischen ihnen nur eine Linie steht und nicht zwei. -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
HorizontalAlignment="Left" Margin="0,0,0,14">
<ToggleButton Content="{Binding L[desktop.tasks.tabJobs]}" Margin="0,0,-1,0"
IsChecked="{Binding IsJobsTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="Jobs" />
<ToggleButton Content="{Binding L[desktop.tasks.tabServices]}" Margin="0,0,-1,0"
IsChecked="{Binding IsServicesTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="Services" />
<ToggleButton Content="{Binding L[desktop.tasks.tabHistory]}"
IsChecked="{Binding IsHistoryTab, Mode=OneWay}"
Command="{Binding SelectTabCommand}" CommandParameter="History" />
</StackPanel>
<!-- Statuszeile des Bereichs -->
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
Text="{Binding StatusText}" TextWrapping="Wrap" />
<Panel>
<!-- ══════════════════ Geplante Jobs ══════════════════ -->
<DockPanel IsVisible="{Binding IsJobsTab}">
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar primary" Command="{Binding AddJobCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.addJob]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding EditJobCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.editJob]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RemoveJobCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.removeJob]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding ToggleJobEnabledCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.toggleJob]}" />
</StackPanel>
</Button>
<Border Classes="sep" />
<Button Classes="toolbar" Command="{Binding RunJobNowCommand}"
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
<TextBlock Text="{Binding L[desktop.tasks.runJobNow]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RefreshJobListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.refresh]}" />
</StackPanel>
</Button>
</WrapPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.tasks.thType]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.tasks.thAgent]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.tasks.thTask]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.tasks.thCron]}" />
<TextBlock Grid.Column="4" Classes="th" Text="{Binding L[desktop.tasks.thNextRun]}" />
<TextBlock Grid.Column="5" Classes="th" Text="{Binding L[desktop.tasks.thLastRun]}" />
<TextBlock Grid.Column="6" Classes="th" Text="{Binding L[desktop.tasks.thStatus]}" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding JobEntries}"
SelectedItem="{Binding SelectedJob}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:JobDisplayItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
<TextBlock Grid.Column="0" Classes="td" Text="{Binding JobType}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding AgentName}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TaskMessage}" />
<TextBlock Grid.Column="3" Classes="td mono" Text="{Binding CronExpression}" />
<TextBlock Grid.Column="4" Classes="td" Text="{Binding NextRun}" />
<TextBlock Grid.Column="5" Classes="td" Text="{Binding LastRun}" />
<Border Grid.Column="6" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Aktiv}">
<TextBlock Text="{Binding Status}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<!-- ══════════════════ Dienste ══════════════════ -->
<DockPanel IsVisible="{Binding IsServicesTab}">
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar primary" Command="{Binding AddServiceCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.services.addService]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RemoveServiceCommand}"
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.services.removeService]}" />
</StackPanel>
</Button>
<Border Classes="sep" />
<!-- Der Entwurf zeigt hier „Start"/„Stop". Umgelegt wird aber das Kennzeichen
in der Konfiguration, nicht ein laufender Prozess — deshalb die ehrlichere
Beschriftung. -->
<Button Classes="toolbar" Command="{Binding ToggleServiceEnabledCommand}"
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.services.toggleService]}" />
</StackPanel>
</Button>
<Button Classes="toolbar" Command="{Binding RefreshServiceListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.tasks.refresh]}" />
</StackPanel>
</Button>
</WrapPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.services.thName]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.services.thType]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.services.thPort]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.services.thStatus]}" />
<TextBlock Grid.Column="4" Classes="th" Text="{Binding L[desktop.services.thDescription]}" />
</Grid>
</Border>
<ListBox Classes="table" ItemsSource="{Binding ServiceEntries}"
SelectedItem="{Binding SelectedService}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:ServiceDisplayItemViewModel">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding Name}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding Type}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Port}" />
<Border Grid.Column="3" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Bereit}">
<TextBlock Text="{Binding Status}" />
</Border>
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding Description}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<!-- ══════════════════ Verlauf ══════════════════ -->
<DockPanel IsVisible="{Binding IsHistoryTab}">
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
<Button Classes="toolbar" Command="{Binding RefreshJobHistoryCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.history.refresh]}" />
</StackPanel>
</Button>
</StackPanel>
<Border DockPanel.Dock="Top" Classes="thead">
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.history.thTime]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.history.thJob]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.history.thAgent]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.history.thDescription]}" />
<TextBlock Grid.Column="4" Classes="th" Text="{Binding L[desktop.history.thStatus]}" />
<TextBlock Grid.Column="5" Classes="th" Text="{Binding L[desktop.history.thDetails]}" />
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding JobHistoryEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:JobHistoryEntry">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
<TextBlock Grid.Column="0" Classes="td"
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
<TextBlock Grid.Column="1" Classes="td" Text="{Binding JobName}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Agent}" />
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding JobDescription}" />
<Border Grid.Column="4" Classes="tag"
Classes.ok="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Success}"
Classes.bad="{Binding Status,
Converter={x:Static conv:StringEqualsConverter.Instance},
ConverterParameter=Error}">
<TextBlock Text="{Binding Status}" />
</Border>
<TextBlock Grid.Column="5" Classes="td dim" Text="{Binding Info}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Panel>
</DockPanel>
</UserControl>
@@ -1,46 +0,0 @@
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;
}
}
@@ -1,35 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="420"
x:Class="ClawdDotNet.Desktop.Views.TextEditorWindow"
Icon="/Assets/app.ico"
Title="Text bearbeiten"
Width="600" Height="420"
MinWidth="420" MinHeight="300"
WindowStartupLocation="CenterOwner">
<!-- Der Rahmen mit Eckmarken sitzt hier wie im Entwurf um den ganzen Dialog. Die
Marken ragen nach aussen, deshalb 12 Punkte Abstand zum Fensterrand. -->
<ctrl:BlueprintFrame Margin="12" Padding="16">
<DockPanel>
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Classes="h1"
Text="Edit Text" Margin="0,0,0,12" TextWrapping="Wrap" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,14,0,0">
<Button x:Name="CancelButton" Content="Cancel" IsCancel="True" Click="OnCancel" />
<Button x:Name="SaveButton" Classes="primary" Content="Save" IsDefault="True" Click="OnSave" />
</StackPanel>
<TextBox x:Name="Editor" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</ctrl:BlueprintFrame>
</Window>
@@ -1,51 +0,0 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace ClawdDotNet.Desktop.Views;
/// <summary>
/// Kleiner Editor für die langen Textfelder eines Agenten (Identity, Soul).
///
/// <para>Ohne eigenes Ansichtsmodell: Der Dialog hat einen Titel, ein Textfeld und zwei
/// Schaltflächen. Ein Ansichtsmodell dafür wäre eine Datei mehr, ohne dass irgendetwas
/// prüfbar würde.</para>
/// </summary>
public partial class TextEditorWindow : Window
{
private string? _result;
public TextEditorWindow()
{
InitializeComponent();
Title = ClawdDotNet.Desktop.Services.LocalizationManager.Instance["desktop.dialog.textEditorTitle"] ?? "Edit Text";
CancelButton.Content = ClawdDotNet.Desktop.Services.LocalizationManager.Instance["common.cancel"] ?? "Cancel";
SaveButton.Content = ClawdDotNet.Desktop.Services.LocalizationManager.Instance["common.save"] ?? "Save";
}
/// <summary>
/// Zeigt den Editor und liefert den bearbeiteten Text — oder <c>null</c>, wenn
/// abgebrochen wurde. Das Schließen über das Fensterkreuz gilt als Abbruch: Sonst
/// wäre ein weggeklickter Dialog dasselbe wie ein bestätigter.
/// </summary>
public static async Task<string?> ShowAsync(Window owner, string title, string text)
{
var window = new TextEditorWindow { Title = title };
window.TitleText.Text = title;
window.Editor.Text = text;
await window.ShowDialog(owner);
return window._result;
}
private void OnSave(object? sender, RoutedEventArgs e)
{
_result = Editor.Text ?? "";
Close();
}
private void OnCancel(object? sender, RoutedEventArgs e)
{
_result = null;
Close();
}
}
@@ -1,114 +0,0 @@
<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:ctrl="using:ClawdDotNet.Desktop.Controls"
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
x:Class="ClawdDotNet.Desktop.Views.TokenUsagePageView"
x:DataType="vm:TokenUsagePageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="20">
<!-- Kennzahlen -->
<StackPanel Orientation="Horizontal" Spacing="12">
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="{Binding L[desktop.usage.kickerTokens7d]}" />
<TextBlock Classes="h2" Text="{Binding TotalTokens}" />
</StackPanel>
</Border>
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="{Binding L[desktop.usage.kickerCost7d]}" />
<TextBlock Classes="h2" Text="{Binding TotalCost}" />
</StackPanel>
</Border>
<Border Classes="card" MinWidth="160">
<StackPanel Spacing="6">
<TextBlock Classes="kicker" Text="{Binding L[desktop.usage.kickerRuns7d]}" />
<TextBlock Classes="h2" Text="{Binding TotalRuns}" />
</StackPanel>
</Border>
<Button Classes="toolbar" Command="{Binding RefreshCommand}" VerticalAlignment="Bottom">
<StackPanel Orientation="Horizontal" Spacing="6">
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
<TextBlock Text="{Binding L[desktop.usage.refresh]}" />
</StackPanel>
</Button>
</StackPanel>
<!-- Hinweis, solange nichts verzeichnet ist -->
<TextBlock Classes="caption" Text="{Binding StatusText}" TextWrapping="Wrap"
IsVisible="{Binding StatusText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Tagesbalken -->
<StackPanel Spacing="12" IsVisible="{Binding !IsEmpty}">
<TextBlock Classes="h2" Text="{Binding L[desktop.usage.tokensPerDay]}" />
<Border BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,0,0,1"
Padding="0,0,0,2">
<ItemsControl ItemsSource="{Binding Days7}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TokenDayItem">
<StackPanel Spacing="6" VerticalAlignment="Bottom"
HorizontalAlignment="Center" Margin="8,0">
<TextBlock Classes="caption" Text="{Binding TokensLabel}"
HorizontalAlignment="Center" />
<Rectangle Width="28" Height="{Binding BarHeight}"
Fill="{DynamicResource AccentBrush}"
VerticalAlignment="Bottom" />
<TextBlock FontSize="11.5" Text="{Binding DayLabel}"
HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<!-- Aufschlüsselung -->
<StackPanel Spacing="10" MaxWidth="860" HorizontalAlignment="Left"
IsVisible="{Binding !IsEmpty}">
<TextBlock Classes="h2" Text="{Binding L[desktop.usage.breakdownTitle]}" />
<Border Classes="thead">
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
<TextBlock Grid.Column="0" Classes="th" Text="{Binding L[desktop.usage.thAgent]}" />
<TextBlock Grid.Column="1" Classes="th" Text="{Binding L[desktop.usage.thModel]}" />
<TextBlock Grid.Column="2" Classes="th" Text="{Binding L[desktop.usage.thTokens]}" />
<TextBlock Grid.Column="3" Classes="th" Text="{Binding L[desktop.usage.thCost]}" />
<TextBlock Grid.Column="4" Classes="th" Text="{Binding L[desktop.usage.thRuns]}" />
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding ByAgent}" Margin="0,-10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TokenAgentItem">
<Border Classes="tr" Padding="7">
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
Text="{Binding AgentName}" />
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TokensLabel}" />
<TextBlock Grid.Column="3" Classes="td" Text="{Binding CostLabel}" />
<TextBlock Grid.Column="4" Classes="td" Text="{Binding Runs}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,8 +0,0 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class TokenUsagePageView : UserControl
{
public TokenUsagePageView() => InitializeComponent();
}
@@ -1,239 +0,0 @@
<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="520" d:DesignHeight="420"
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsView"
x:DataType="vm:ToolSettingsViewModel">
<!--
Die Felder eines Werkzeugs — im Entwurf unten rechts auf der Agenten-Seite, nicht
mehr in einem eigenen Fenster. Deshalb ein UserControl und kein Window: Der Inhalt
ist derselbe, der Rahmen drumherum entscheidet der Aufrufer.
Flach statt in Karten und ohne verschachtelte Registerkarten (siehe
Implementierungsleitfaden im Mockup-Verzeichnis): Beschriftung ueber dem Feld,
hoechstens zwei Spalten.
-->
<StackPanel Spacing="12" MaxWidth="520" HorizontalAlignment="Left">
<!-- 1. FTP -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.host]}" />
<TextBox Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.port]}" />
<TextBox Text="{Binding FtpPort}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.username]}" />
<TextBox Text="{Binding FtpUsername}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.password]}" />
<TextBox Text="{Binding FtpPassword}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.rootPath]}" />
<TextBox Text="{Binding FtpRootPath}" />
</StackPanel>
</StackPanel>
<!-- 2. Mail -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.username]}" />
<TextBox Text="{Binding MailUsername}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.password]}" />
<TextBox Text="{Binding MailPassword}" PasswordChar="•" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.imapHost]}" />
<TextBox Text="{Binding MailImapHost}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.imapPort]}" />
<TextBox Text="{Binding MailImapPort}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.smtpHost]}" />
<TextBox Text="{Binding MailSmtpHost}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.smtpPort]}" />
<TextBox Text="{Binding MailSmtpPort}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.allowedRecipients]}" />
<TextBox Text="{Binding MailAllowedRecipients}"
PlaceholderText="adresse1@domain.de, adresse2@domain.de" />
</StackPanel>
</StackPanel>
<!-- 3. Database -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.type]}" />
<ComboBox ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.connectionString]}" />
<TextBox Text="{Binding DbConnectionString}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.accessLevel]}" />
<ComboBox ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.allowedTables]}" />
<TextBox Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" />
</StackPanel>
</StackPanel>
<!-- 4. Telegram -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.botToken]}" />
<TextBox Text="{Binding TelegramBotToken}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.defaultChatId]}" />
<TextBox Text="{Binding TelegramDefaultChatId}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.allowedChatIds]}" />
<TextBox Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" />
</StackPanel>
</StackPanel>
<!-- 5. DirectAPI -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.defaultProvider]}" />
<TextBox Text="{Binding DirectApiDefaultProvider}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.cacheTtl]}" />
<TextBox Text="{Binding DirectApiCacheTtlSeconds}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.twelveDataKey]}" />
<TextBox Text="{Binding DirectApiTwelveDataKey}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.alphaVantageKey]}" />
<TextBox Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="•" />
</StackPanel>
</StackPanel>
<!-- 6. WebFetch -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.allowedDomains]}" />
<TextBox Text="{Binding WebFetchAllowedDomains}"
PlaceholderText="reuters.com, coindesk.com" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.maxResponseKb]}" />
<TextBox Text="{Binding WebFetchMaxResponseKb}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.userAgent]}" />
<TextBox Text="{Binding WebFetchUserAgent}" />
</StackPanel>
</Grid>
</StackPanel>
<!-- 7. SocialMediaManager -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.xApiKey]}" />
<TextBox Text="{Binding SmmXApiKey}" PasswordChar="•" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.xAccounts]}" />
<TextBox Text="{Binding SmmXWatchAccounts}" PlaceholderText="account1, account2" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.subreddits]}" />
<TextBox Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="stocks, investing" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.redditPostLimit]}" />
<TextBox Text="{Binding SmmRedditPostLimit}" />
</StackPanel>
</Grid>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.openRouterKey]}" />
<TextBox Text="{Binding SmmOpenRouterApiKey}" PasswordChar="•" />
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.sttModel]}" />
<TextBox Text="{Binding SmmSttModel}" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.youtubeChannels]}" />
<TextBox Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" />
</StackPanel>
</Grid>
</StackPanel>
<!-- 8. FileRW -->
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.fileRwPersonalExt]}" />
<TextBox Text="{Binding FileRwPersonalAllowedExtensions}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.fileRwSharedAccess]}" />
<ComboBox ItemsSource="{Binding FileRwAccessLevels}"
SelectedItem="{Binding FileRwSharedAccessLevel}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.fileRwSharedExt]}" />
<TextBox Text="{Binding FileRwSharedAllowedExtensions}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="{Binding L[desktop.toolSettings.fileRwProtectedPaths]}" />
<TextBox Text="{Binding FileRwProtectedPaths}" />
</StackPanel>
</StackPanel>
<!-- Werkzeuge ohne weitere Einstellungen -->
<TextBlock Classes="muted" FontStyle="Italic" TextWrapping="Wrap"
Text="{Binding L[desktop.toolSettings.noSettings]}"
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}" />
</StackPanel>
</UserControl>
@@ -1,8 +0,0 @@
using Avalonia.Controls;
namespace ClawdDotNet.Desktop.Views;
public partial class ToolSettingsView : UserControl
{
public ToolSettingsView() => InitializeComponent();
}
-341
View File
@@ -1,341 +0,0 @@
{
"desktop.mainWindow.title": "ClawdDotNet",
"desktop.mainWindow.sidebarTooltip": "Seitenleiste ein-/ausklappen",
"desktop.mainWindow.switchInstance": "Instanz wechseln",
"desktop.mainWindow.instance": "INSTANZ",
"desktop.mainWindow.themeLight": "Hellmodus",
"desktop.mainWindow.themeDark": "Dunkelmodus",
"desktop.mainWindow.themeToggle": "Farbschema umschalten",
"desktop.mainWindow.openInstanceFolder": "Instanz-Ordner öffnen",
"desktop.mainWindow.instanceManager": "Instanzmanager",
"desktop.mainWindow.exit": "Beenden",
"desktop.mainWindow.instanceLabel": "Instanz: {0}",
"desktop.mainWindow.apiStatus": "API {0}",
"desktop.mainWindow.credits": "Credits: ${0:F2} von ${1:F2}",
"desktop.mainWindow.creditsNone": "Credits: —",
"desktop.mainWindow.dismiss": "Ausblenden",
"desktop.nav.work": "Arbeiten",
"desktop.nav.analytics": "Analyse",
"desktop.nav.system": "System",
"desktop.status.unknown": "unbekannt",
"desktop.status.design": "Entwurf",
"desktop.status.noApiKey": "kein API-Key",
"desktop.status.noUsageData": "keine Nutzungsdaten",
"desktop.status.ready": "Bereit",
"desktop.status.reachable": "OK",
"desktop.status.unreachable": "nicht erreichbar",
"desktop.chat.agent": "Agent:",
"desktop.chat.clearHistory": "Verlauf leeren",
"desktop.chat.abort": "Abbrechen",
"desktop.chat.inputPlaceholder": "Nachricht eingeben… (Enter zum Senden, Shift+Enter für neue Zeile)",
"desktop.chat.send": "Senden",
"desktop.chat.statusReady": "Bereit",
"desktop.chat.statusSending": "Sende…",
"desktop.chat.statusDesign": "Entwurfsmodus",
"desktop.chat.noApiKey": "OpenRouter API-Key ist nicht konfiguriert. Bitte in den Einstellungen hinterlegen.",
"desktop.chat.noAgent": "Bitte einen Agenten auswählen.",
"desktop.chat.emptyMessage": "Bitte eine Nachricht eingeben.",
"desktop.agents.addAgent": "Agent",
"desktop.agents.removeAgent": "Agent",
"desktop.agents.runNow": "Jetzt ausf.",
"desktop.agents.identity": "Identity",
"desktop.agents.soul": "Soul",
"desktop.agents.folder": "Ordner",
"desktop.agents.save": "Speichern",
"desktop.agents.thDisplayName": "ANZEIGENAME",
"desktop.agents.thModel": "MODELL",
"desktop.agents.thSummary": "KURZINFO",
"desktop.agents.kickerGeneralModel": "ALLGEMEIN & MODELL",
"desktop.agents.agentId": "Agent-ID",
"desktop.agents.displayName": "Anzeigename",
"desktop.agents.model": "Modell",
"desktop.agents.promptCaching": "Prompt Caching",
"desktop.agents.roleDescription": "Rolle / Beschreibung",
"desktop.agents.systemPrompt": "System-Prompt (zusätzliche Anweisungen)",
"desktop.agents.kickerLimits": "GRENZEN",
"desktop.agents.limitsTitle": "Schutzgrenzen (LoopGuard & Budget)",
"desktop.agents.maxSteps": "Max. Schritte pro Run",
"desktop.agents.timeoutSeconds": "Timeout (Sekunden)",
"desktop.agents.maxContextTokens": "Max. Kontext-Tokens",
"desktop.agents.maxToolResultChars": "Max. Tool-Ergebnis (Zeichen)",
"desktop.agents.dailyCost": "Tagesbudget ($ USD)",
"desktop.agents.dailyTokens": "Tagesbudget (Tokens)",
"desktop.agents.whenExhausted": "Wenn das Budget erschöpft ist (wiederkehrende Aufgaben)",
"desktop.agents.whenExhaustedHint": "expire: Der verpasste Lauf verfällt und wird an der Aufgabe vermerkt. catchUpOnce: Alle verpassten Läufe werden am nächsten Tag durch einen Lauf nachgeholt, nach der regulären Arbeit des Tages. Einmalige Aufgaben werden immer auf den nächsten Tag mitgenommen.",
"desktop.agents.allowedToolsFor": "ZUGELASSENE WERKZEUGE FÜR {0}",
"desktop.agents.thTool": "WERKZEUG",
"desktop.agents.thDescription": "BESCHREIBUNG",
"desktop.agents.toolSettingsKicker": "WERKZEUG-EINSTELLUNGEN",
"desktop.agents.apply": "Übernehmen",
"desktop.agents.saved": "Agenten erfolgreich gespeichert.",
"desktop.tasks.tabJobs": "Geplante Jobs",
"desktop.tasks.tabServices": "Dienste",
"desktop.tasks.tabHistory": "Verlauf",
"desktop.tasks.addJob": "Job hinzufügen",
"desktop.tasks.editJob": "Bearbeiten",
"desktop.tasks.removeJob": "Entfernen",
"desktop.tasks.toggleJob": "Aktivieren/Deaktivieren",
"desktop.tasks.runJobNow": "Jetzt ausführen",
"desktop.tasks.refresh": "Aktualisieren",
"desktop.tasks.thType": "TYP",
"desktop.tasks.thAgent": "AGENT",
"desktop.tasks.thTask": "TOOL / NACHRICHT",
"desktop.tasks.thCron": "CRON",
"desktop.tasks.thNextRun": "NÄCHSTER LAUF",
"desktop.tasks.thLastRun": "LETZTER LAUF",
"desktop.tasks.thStatus": "STATUS",
"desktop.services.addService": "Dienst hinzufügen",
"desktop.services.removeService": "Entfernen",
"desktop.services.toggleService": "Aktivieren/Deaktivieren",
"desktop.services.thName": "DIENSTNAME",
"desktop.services.thType": "TYP",
"desktop.services.thPort": "PORT",
"desktop.services.thStatus": "STATUS",
"desktop.services.thDescription": "BESCHREIBUNG",
"desktop.history.refresh": "Verlauf aktualisieren",
"desktop.history.thTime": "ZEITPUNKT",
"desktop.history.thJob": "JOB",
"desktop.history.thAgent": "AGENT",
"desktop.history.thDescription": "BESCHREIBUNG",
"desktop.history.thStatus": "STATUS",
"desktop.history.thDetails": "DETAILS",
"desktop.usage.kickerTokens7d": "TOKENS (7 TAGE)",
"desktop.usage.kickerCost7d": "KOSTEN (7 TAGE)",
"desktop.usage.kickerRuns7d": "RUNS (7 TAGE)",
"desktop.usage.refresh": "Aktualisieren",
"desktop.usage.tokensPerDay": "Tokens pro Tag",
"desktop.usage.breakdownTitle": "Aufschlüsselung nach Agent & Modell",
"desktop.usage.thAgent": "AGENT",
"desktop.usage.thModel": "MODELL",
"desktop.usage.thTokens": "TOKENS",
"desktop.usage.thCost": "KOSTEN",
"desktop.usage.thRuns": "RUNS",
"desktop.usage.empty": "Noch keine Tokennutzung aufgezeichnet.",
"desktop.agentChats.agentA": "Agent A:",
"desktop.agentChats.agentB": "Agent B:",
"desktop.agentChats.empty": "Noch keine Nachrichten zwischen diesen Agenten aufgezeichnet.",
"desktop.agentChats.needTwo": "Für einen Verlauf braucht es mindestens zwei Agenten in dieser Instanz.",
"desktop.backup.kickerManual": "MANUELL",
"desktop.backup.titleCreate": "Sicherung erstellen",
"desktop.backup.targetFolder": "Zielordner",
"desktop.backup.targetFolderPlaceholder": "Zielordner für Sicherungen…",
"desktop.backup.browse": "Durchsuchen…",
"desktop.backup.credentials": "Zugangsdaten",
"desktop.backup.credentialsExclude": "nicht mitsichern",
"desktop.backup.credentialsProtect": "mit Passphrase schützen",
"desktop.backup.passphrase": "Passphrase eingeben",
"desktop.backup.passphraseRepeat": "Passphrase wiederholen",
"desktop.backup.scope": "Umfang",
"desktop.backup.scopeChat": "Chatverläufe",
"desktop.backup.scopeLogs": "Protokolle",
"desktop.backup.createNow": "Sicherung jetzt erstellen",
"desktop.backup.kickerAuto": "AUTOMATIK",
"desktop.backup.titleAuto": "Automatische Sicherung",
"desktop.backup.autoDaily": "täglich sichern um",
"desktop.backup.time": "Uhrzeit",
"desktop.backup.keep": "Aufbewahren",
"desktop.backup.autoHint": "Die Automatik sichert ohne Zugangsdaten — eine gespeicherte Passphrase wäre wirkungslos.",
"desktop.backup.kickerExisting": "BESTAND",
"desktop.backup.titleExisting": "Vorhandene Sicherungen",
"desktop.backup.refresh": "Aktualisieren",
"desktop.backup.openFolder": "Ordner öffnen",
"desktop.backup.delete": "Löschen",
"desktop.backup.restore": "Wiederherstellen…",
"desktop.backup.thFilename": "DATEINAME",
"desktop.backup.thCreatedAt": "ERSTELLT AM",
"desktop.backup.thInstance": "INSTANZ",
"desktop.backup.thSize": "GRÖSSE",
"desktop.backup.thSecrets": "ZUGANGSDATEN",
"desktop.logs.module": "Modul:",
"desktop.logs.level": "Stufe:",
"desktop.logs.refresh": "Aktualisieren",
"desktop.logs.clear": "Leeren",
"desktop.logs.openFolder": "Ordner öffnen",
"desktop.logs.follow": "Mitlaufen",
"desktop.settings.discard": "Verwerfen",
"desktop.settings.save": "Speichern",
"desktop.settings.instance": "INSTANZ",
"desktop.settings.instanceName": "Instanz-Name",
"desktop.settings.apiKey": "OpenRouter API-Key",
"desktop.settings.autostart": "Autostart durch Manager",
"desktop.settings.autostartHint": "Der Instanzmanager startet diese Instanz, wenn er hochkommt, und wieder, wenn sie abstürzt. Nach mehreren Neustarts in kurzer Folge setzt er aus und meldet es.",
"desktop.settings.logDir": "Protokoll-Verzeichnis",
"desktop.settings.logDirHint": "Gilt nur für diese Instanz. Leer heißt: Unterordner Logs im Instanzverzeichnis.",
"desktop.settings.dailyCost": "Tagesbudget ($ USD)",
"desktop.settings.dailyTokens": "Tagesbudget (Tokens)",
"desktop.settings.general": "ALLGEMEIN",
"desktop.settings.instancesDir": "Instanzen-Verzeichnis",
"desktop.settings.minLogLevel": "Minimaler Log-Level",
"desktop.settings.openRouterUrl": "OpenRouter Base-URL",
"desktop.settings.language": "Sprache",
"desktop.settings.dcAndLicense": "DEPLOYMENTCENTER & LIZENZ",
"desktop.settings.dcUrl": "Server-URL",
"desktop.settings.dcToken": "Master-Token",
"desktop.settings.dcEnv": "Umgebung",
"desktop.settings.updateChannel": "Update-Kanal",
"desktop.settings.licenseKey": "Lizenzschlüssel",
"desktop.settings.errorReporting": "Fehler automatisch an Deploymentcenter melden",
"desktop.settings.updateCheck": "Beim Start automatisch auf Updates prüfen",
"desktop.settings.saved": "Einstellungen und Instanz-Konfiguration gespeichert.",
"desktop.settings.savedLogRestart": "Gespeichert. Das Protokoll-Verzeichnis gilt ab dem nächsten Start.",
"desktop.info.kickerApp": "ANWENDUNG",
"desktop.info.build": "Build",
"desktop.info.version": "Version",
"desktop.info.buildSummary": "Build-Zusammenfassung",
"desktop.info.kickerInstance": "INSTANZ",
"desktop.info.currentInstance": "Aktuelle Instanz",
"desktop.info.openFolder": "Instanz-Ordner öffnen",
"desktop.info.name": "Name",
"desktop.info.id": "ID",
"desktop.info.path": "Pfad",
"desktop.info.logDir": "Log-Ordner",
"desktop.info.openRouter": "OpenRouter",
"desktop.info.kickerModules": "MODULE",
"desktop.info.assemblies": "Baugruppen",
"desktop.info.thModule": "MODUL",
"desktop.info.thBuild": "BUILD",
"desktop.info.thBuildDate": "BUILD-DATUM",
"desktop.info.thChanges": "ÄNDERUNGEN",
"desktop.dialog.addAgent": "Neuen Agenten anlegen",
"desktop.dialog.agentId": "Agent-ID (eindeutiger Schlüssel):",
"desktop.dialog.agentDisplayName": "Anzeigename:",
"desktop.dialog.defaultModel": "Standard-Modell:",
"desktop.dialog.roleDescription": "Beschreibung / Rolle:",
"desktop.dialog.addJobTitle": "Job hinzufügen / bearbeiten",
"desktop.dialog.jobType": "Job-Typ:",
"desktop.dialog.agentWakeup": "Agent Wakeup (Periodische Ausführung)",
"desktop.dialog.toolJob": "Tool Job (Werkzeug-spezifischer Job)",
"desktop.dialog.jobTypeId": "Job-Typ-ID / Name:",
"desktop.dialog.taskMessage": "Aufgabenbeschreibung / Nachricht:",
"desktop.dialog.cronExpression": "Cron-Ausdruck:",
"desktop.dialog.cronHint": "Syntax: Min Std Tag Mon Wochentag (z.B. '*/15 * * * *' alle 15 Minuten)",
"desktop.dialog.runOnStart": "Beim Anwendungsstart einmalig sofort ausführen",
"desktop.dialog.addServiceTitle": "Dienst (Service) hinzufügen",
"desktop.dialog.serviceName": "Name des Dienstes:",
"desktop.dialog.serviceType": "Typ:",
"desktop.dialog.servicePort": "Port:",
"desktop.dialog.serviceDesc": "Beschreibung:",
"desktop.dialog.restoreTitle": "Sicherung wiederherstellen",
"desktop.dialog.backupInfo": "Sicherungsinformationen",
"desktop.dialog.backupInstance": "Instanz: {0}",
"desktop.dialog.backupCreated": "Erstellt: {0:g}",
"desktop.dialog.backupFiles": "Dateien: {0}",
"desktop.dialog.backupSecrets": "Zugangsdaten: {0}",
"desktop.dialog.targetDir": "Zielordner:",
"desktop.dialog.chooseFolder": "Ordner wählen",
"desktop.dialog.restoreHint": "Hinweis: Es wird empfohlen, in einen neuen Ordner wiederherzustellen, um Konflikte mit laufenden Prozessen zu vermeiden.",
"desktop.dialog.passphrase": "Passphrase:",
"desktop.dialog.passphraseProtected": "Diese Sicherung enthält geschützte Zugangsdaten.",
"desktop.dialog.overwriteExisting": "Vorhandene Dateien im Zielordner überschreiben",
"desktop.dialog.licenseTitle": "ClawdDotNet – Lizenz",
"desktop.dialog.licenseKey": "Lizenzschlüssel",
"desktop.dialog.hardwareId": "Hardware-ID (für den Support):",
"desktop.dialog.verify": "Prüfen",
"desktop.dialog.textEditorTitle": "Text bearbeiten",
"desktop.instancePicker.titleSwitch": "Instanz wechseln",
"desktop.instancePicker.titleSelect": "Instanz wählen",
"desktop.instancePicker.hintSwitch": "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten. Wechseln startet ClawdDotNet mit der gewählten Instanz neu.",
"desktop.instancePicker.hintSelect": "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten.",
"desktop.instancePicker.confirmSwitch": "Wechseln",
"desktop.instancePicker.confirmStart": "Starten",
"desktop.instancePicker.newInstancePlaceholder": "Name der neuen Instanz",
"desktop.instancePicker.create": "Anlegen",
"desktop.instancePicker.active": "Aktiv",
"desktop.instancePicker.dirLabel": "Verzeichnis: {0}",
"desktop.instancePicker.agentsCount": "{0} Agenten · API-Key {1}",
"desktop.instancePicker.apiKeyConfigured": "konfiguriert",
"desktop.instancePicker.apiKeyMissing": "fehlt",
"desktop.instancePicker.tooltipConfigured": "API-Key hinterlegt",
"desktop.instancePicker.tooltipMissing": "Kein API-Key — Agenten bleiben deaktiviert",
"desktop.instancePicker.nameRequired": "Bitte einen Instanznamen eingeben.",
"desktop.instancePicker.createFailed": "Instanz konnte nicht angelegt werden: {0}",
"desktop.startMode.title": "ClawdDotNet – Startmodus wählen",
"desktop.startMode.question": "Wie möchtest du ClawdDotNet starten?",
"desktop.startMode.managerBtn": "Instanzmanager starten (Empfohlen)",
"desktop.startMode.standaloneBtn": "Als Standalone-Instanz starten",
"desktop.startMode.explanation": "• Instanzmanager (Empfohlen):\n Startet den zentralen Hintergrund-Manager und öffnet die Verwaltung im Browser. Ermöglicht den stabilen Dauerbetrieb mehrerer Instanzen parallel ohne Absturzrisiko.\n\n• Standalone-Modus:\n Startet genau eine Instanz direkt als einzelnes Desktop-Fenster.",
"desktop.standaloneSwitch.title": "ClawdDotNet – Instanzwechsel (Standalone)",
"desktop.standaloneSwitch.warning": "Achtung: Standalone-Modus",
"desktop.standaloneSwitch.text": "Diese Instanz läuft eigenständig ohne Instanzmanager.\n\nWenn du die Instanz hier wechselst, wird diese Instanz vollständig beendet und alle laufenden Agenten und Hintergrunddienste werden gestoppt.\n\nEmpfehlung: Starte stattdessen den Instanzmanager, um mehrere Instanzen gleichzeitig und unabhängig voneinander zu betreiben.",
"desktop.standaloneSwitch.switchAnyway": "Instanz trotzdem wechseln",
"desktop.toolSettings.noSettings": "Dieses Werkzeug benötigt keine weiteren Einstellungen.",
"desktop.toolSettings.host": "Host",
"desktop.toolSettings.port": "Port",
"desktop.toolSettings.username": "Benutzername",
"desktop.toolSettings.password": "Passwort",
"desktop.toolSettings.rootPath": "Root-Pfad",
"desktop.toolSettings.imapHost": "IMAP-Host",
"desktop.toolSettings.imapPort": "IMAP-Port",
"desktop.toolSettings.smtpHost": "SMTP-Host",
"desktop.toolSettings.smtpPort": "SMTP-Port",
"desktop.toolSettings.allowedRecipients": "Erlaubte Empfänger",
"desktop.toolSettings.type": "Typ",
"desktop.toolSettings.connectionString": "Connection-String",
"desktop.toolSettings.accessLevel": "Zugriffsebene",
"desktop.toolSettings.allowedTables": "Erlaubte Tabellen",
"desktop.toolSettings.botToken": "Bot-Token",
"desktop.toolSettings.defaultChatId": "Standard Chat-ID",
"desktop.toolSettings.allowedChatIds": "Erlaubte Chat-IDs",
"desktop.toolSettings.defaultProvider": "Standard-Provider",
"desktop.toolSettings.cacheTtl": "Cache TTL (Sekunden)",
"desktop.toolSettings.twelveDataKey": "Twelve Data Key",
"desktop.toolSettings.alphaVantageKey": "Alpha Vantage Key",
"desktop.toolSettings.allowedDomains": "Erlaubte Domains",
"desktop.toolSettings.maxResponseKb": "Max. Antwortgröße (KB)",
"desktop.toolSettings.userAgent": "User Agent",
"desktop.toolSettings.xApiKey": "X (Twitter) API-Key",
"desktop.toolSettings.xAccounts": "X Accounts",
"desktop.toolSettings.subreddits": "Subreddits",
"desktop.toolSettings.redditPostLimit": "Reddit Post Limit",
"desktop.toolSettings.openRouterKey": "OpenRouter Key",
"desktop.toolSettings.sttModel": "STT-Modell",
"desktop.toolSettings.youtubeChannels": "YouTube-Kanäle",
"desktop.toolSettings.fileRwPersonalExt": "Erlaubte Dateiendungen (persönlich)",
"desktop.toolSettings.fileRwSharedAccess": "Zugriffsebene (geteilt)",
"desktop.toolSettings.fileRwSharedExt": "Erlaubte Dateiendungen (geteilt)",
"desktop.toolSettings.fileRwProtectedPaths": "Geschützte Pfade",
"nav.chat": "Chat",
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
"nav.usage": "Tokennutzung",
"nav.agentChats": "Agenten-Chats",
"nav.backup": "Sicherung",
"nav.logs": "Protokoll",
"nav.settings": "Einstellungen",
"nav.info": "Info",
"common.all": "Alle",
"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.delete": "Löschen",
"common.edit": "Bearbeiten",
"common.refresh": "Aktualisieren",
"common.close": "Schließen",
"common.discard": "Verwerfen",
"common.ready": "Bereit",
"common.loading": "Lade…",
"common.create": "Anlegen",
"common.add": "Hinzufügen",
"common.apply": "Übernehmen",
"common.ok": "OK",
"common.exit": "Beenden"
}
-341
View File
@@ -1,341 +0,0 @@
{
"desktop.mainWindow.title": "ClawdDotNet",
"desktop.mainWindow.sidebarTooltip": "Toggle sidebar",
"desktop.mainWindow.switchInstance": "Switch instance",
"desktop.mainWindow.instance": "INSTANCE",
"desktop.mainWindow.themeLight": "Light Mode",
"desktop.mainWindow.themeDark": "Dark Mode",
"desktop.mainWindow.themeToggle": "Toggle theme",
"desktop.mainWindow.openInstanceFolder": "Open Instance Folder",
"desktop.mainWindow.instanceManager": "Instance Manager",
"desktop.mainWindow.exit": "Exit",
"desktop.mainWindow.instanceLabel": "Instance: {0}",
"desktop.mainWindow.apiStatus": "API {0}",
"desktop.mainWindow.credits": "Credits: ${0:F2} of ${1:F2}",
"desktop.mainWindow.creditsNone": "Credits: —",
"desktop.mainWindow.dismiss": "Dismiss",
"desktop.nav.work": "Work",
"desktop.nav.analytics": "Analytics",
"desktop.nav.system": "System",
"desktop.status.unknown": "unknown",
"desktop.status.design": "Design",
"desktop.status.noApiKey": "no API key",
"desktop.status.noUsageData": "no usage data",
"desktop.status.ready": "Ready",
"desktop.status.reachable": "OK",
"desktop.status.unreachable": "unreachable",
"desktop.chat.agent": "Agent:",
"desktop.chat.clearHistory": "Clear History",
"desktop.chat.abort": "Abort",
"desktop.chat.inputPlaceholder": "Type message… (Enter to send, Shift+Enter for new line)",
"desktop.chat.send": "Send",
"desktop.chat.statusReady": "Ready",
"desktop.chat.statusSending": "Sending…",
"desktop.chat.statusDesign": "Design Mode",
"desktop.chat.noApiKey": "OpenRouter API key is not configured. Please enter the key in Settings.",
"desktop.chat.noAgent": "Please select an agent.",
"desktop.chat.emptyMessage": "Please enter a message.",
"desktop.agents.addAgent": "Agent",
"desktop.agents.removeAgent": "Agent",
"desktop.agents.runNow": "Run Now",
"desktop.agents.identity": "Identity",
"desktop.agents.soul": "Soul",
"desktop.agents.folder": "Folder",
"desktop.agents.save": "Save",
"desktop.agents.thDisplayName": "DISPLAY NAME",
"desktop.agents.thModel": "MODEL",
"desktop.agents.thSummary": "SUMMARY",
"desktop.agents.kickerGeneralModel": "GENERAL & MODEL",
"desktop.agents.agentId": "Agent ID",
"desktop.agents.displayName": "Display Name",
"desktop.agents.model": "Model",
"desktop.agents.promptCaching": "Prompt Caching",
"desktop.agents.roleDescription": "Role / Description",
"desktop.agents.systemPrompt": "System Prompt (additional instructions)",
"desktop.agents.kickerLimits": "LIMITS",
"desktop.agents.limitsTitle": "Guardrails (LoopGuard & Budget)",
"desktop.agents.maxSteps": "Max Steps per Run",
"desktop.agents.timeoutSeconds": "Timeout (seconds)",
"desktop.agents.maxContextTokens": "Max Context Tokens",
"desktop.agents.maxToolResultChars": "Max Tool Result (chars)",
"desktop.agents.dailyCost": "Daily Budget ($ USD)",
"desktop.agents.dailyTokens": "Daily Budget (Tokens)",
"desktop.agents.whenExhausted": "When the budget is exhausted (recurring tasks)",
"desktop.agents.whenExhaustedHint": "expire: the missed run lapses and is recorded on the task. catchUpOnce: all missed runs are caught up by one run the next day, after the day's regular work. One-time tasks are always carried over to the next day.",
"desktop.agents.allowedToolsFor": "ALLOWED TOOLS FOR {0}",
"desktop.agents.thTool": "TOOL",
"desktop.agents.thDescription": "DESCRIPTION",
"desktop.agents.toolSettingsKicker": "TOOL SETTINGS",
"desktop.agents.apply": "Apply",
"desktop.agents.saved": "Agents saved successfully.",
"desktop.tasks.tabJobs": "Scheduled Jobs",
"desktop.tasks.tabServices": "Services",
"desktop.tasks.tabHistory": "History",
"desktop.tasks.addJob": "Add Job",
"desktop.tasks.editJob": "Edit",
"desktop.tasks.removeJob": "Remove",
"desktop.tasks.toggleJob": "Enable/Disable",
"desktop.tasks.runJobNow": "Run Now",
"desktop.tasks.refresh": "Refresh",
"desktop.tasks.thType": "TYPE",
"desktop.tasks.thAgent": "AGENT",
"desktop.tasks.thTask": "TOOL / MESSAGE",
"desktop.tasks.thCron": "CRON",
"desktop.tasks.thNextRun": "NEXT RUN",
"desktop.tasks.thLastRun": "LAST RUN",
"desktop.tasks.thStatus": "STATUS",
"desktop.services.addService": "Add Service",
"desktop.services.removeService": "Remove",
"desktop.services.toggleService": "Enable/Disable",
"desktop.services.thName": "SERVICE NAME",
"desktop.services.thType": "TYPE",
"desktop.services.thPort": "PORT",
"desktop.services.thStatus": "STATUS",
"desktop.services.thDescription": "DESCRIPTION",
"desktop.history.refresh": "Refresh History",
"desktop.history.thTime": "TIMESTAMP",
"desktop.history.thJob": "JOB",
"desktop.history.thAgent": "AGENT",
"desktop.history.thDescription": "DESCRIPTION",
"desktop.history.thStatus": "STATUS",
"desktop.history.thDetails": "DETAILS",
"desktop.usage.kickerTokens7d": "TOKENS (7 DAYS)",
"desktop.usage.kickerCost7d": "COST (7 DAYS)",
"desktop.usage.kickerRuns7d": "RUNS (7 DAYS)",
"desktop.usage.refresh": "Refresh",
"desktop.usage.tokensPerDay": "Tokens per Day",
"desktop.usage.breakdownTitle": "Breakdown by Agent & Model",
"desktop.usage.thAgent": "AGENT",
"desktop.usage.thModel": "MODEL",
"desktop.usage.thTokens": "TOKENS",
"desktop.usage.thCost": "COST",
"desktop.usage.thRuns": "RUNS",
"desktop.usage.empty": "No token usage recorded yet.",
"desktop.agentChats.agentA": "Agent A:",
"desktop.agentChats.agentB": "Agent B:",
"desktop.agentChats.empty": "No messages between these agents yet.",
"desktop.agentChats.needTwo": "At least two agents are required in this instance to view a conversation history.",
"desktop.backup.kickerManual": "MANUAL",
"desktop.backup.titleCreate": "Create Backup",
"desktop.backup.targetFolder": "Target Folder",
"desktop.backup.targetFolderPlaceholder": "Target folder for backups…",
"desktop.backup.browse": "Browse…",
"desktop.backup.credentials": "Credentials",
"desktop.backup.credentialsExclude": "do not include",
"desktop.backup.credentialsProtect": "protect with passphrase",
"desktop.backup.passphrase": "Enter passphrase",
"desktop.backup.passphraseRepeat": "Repeat passphrase",
"desktop.backup.scope": "Scope",
"desktop.backup.scopeChat": "Chat history",
"desktop.backup.scopeLogs": "Logs",
"desktop.backup.createNow": "Create Backup Now",
"desktop.backup.kickerAuto": "AUTOMATIC",
"desktop.backup.titleAuto": "Automatic Backup",
"desktop.backup.autoDaily": "daily backup at",
"desktop.backup.time": "Time",
"desktop.backup.keep": "Keep Count",
"desktop.backup.autoHint": "Automatic backup excludes credentials — a saved passphrase would be ineffective.",
"desktop.backup.kickerExisting": "EXISTING",
"desktop.backup.titleExisting": "Existing Backups",
"desktop.backup.refresh": "Refresh",
"desktop.backup.openFolder": "Open Folder",
"desktop.backup.delete": "Delete",
"desktop.backup.restore": "Restore…",
"desktop.backup.thFilename": "FILENAME",
"desktop.backup.thCreatedAt": "CREATED AT",
"desktop.backup.thInstance": "INSTANCE",
"desktop.backup.thSize": "SIZE",
"desktop.backup.thSecrets": "CREDENTIALS",
"desktop.logs.module": "Module:",
"desktop.logs.level": "Level:",
"desktop.logs.refresh": "Refresh",
"desktop.logs.clear": "Clear",
"desktop.logs.openFolder": "Open Folder",
"desktop.logs.follow": "Follow Tail",
"desktop.settings.discard": "Discard",
"desktop.settings.save": "Save",
"desktop.settings.instance": "INSTANCE",
"desktop.settings.instanceName": "Instance Name",
"desktop.settings.apiKey": "OpenRouter API Key",
"desktop.settings.autostart": "Autostart by Manager",
"desktop.settings.autostartHint": "The instance manager starts this instance on launch and restarts it if it crashes.",
"desktop.settings.logDir": "Log Directory",
"desktop.settings.logDirHint": "Applies only to this instance. Empty means: Logs subfolder in instance directory.",
"desktop.settings.dailyCost": "Daily Budget ($ USD)",
"desktop.settings.dailyTokens": "Daily Budget (Tokens)",
"desktop.settings.general": "GENERAL",
"desktop.settings.instancesDir": "Instances Directory",
"desktop.settings.minLogLevel": "Minimum Log Level",
"desktop.settings.openRouterUrl": "OpenRouter Base URL",
"desktop.settings.language": "Language",
"desktop.settings.dcAndLicense": "DEPLOYMENTCENTER & LICENSE",
"desktop.settings.dcUrl": "Server URL",
"desktop.settings.dcToken": "Master Token",
"desktop.settings.dcEnv": "Environment",
"desktop.settings.updateChannel": "Update Channel",
"desktop.settings.licenseKey": "License Key",
"desktop.settings.errorReporting": "Automatically report errors to Deploymentcenter",
"desktop.settings.updateCheck": "Check for updates automatically on startup",
"desktop.settings.saved": "Settings and instance configuration saved successfully.",
"desktop.settings.savedLogRestart": "Saved. The log directory will take effect upon next restart.",
"desktop.info.kickerApp": "APPLICATION",
"desktop.info.build": "Build",
"desktop.info.version": "Version",
"desktop.info.buildSummary": "Build Summary",
"desktop.info.kickerInstance": "INSTANCE",
"desktop.info.currentInstance": "Current Instance",
"desktop.info.openFolder": "Open Instance Folder",
"desktop.info.name": "Name",
"desktop.info.id": "ID",
"desktop.info.path": "Path",
"desktop.info.logDir": "Log Directory",
"desktop.info.openRouter": "OpenRouter",
"desktop.info.kickerModules": "MODULES",
"desktop.info.assemblies": "Assemblies",
"desktop.info.thModule": "MODULE",
"desktop.info.thBuild": "BUILD",
"desktop.info.thBuildDate": "BUILD DATE",
"desktop.info.thChanges": "CHANGES",
"desktop.dialog.addAgent": "Create New Agent",
"desktop.dialog.agentId": "Agent ID (unique key):",
"desktop.dialog.agentDisplayName": "Display Name:",
"desktop.dialog.defaultModel": "Default Model:",
"desktop.dialog.roleDescription": "Description / Role:",
"desktop.dialog.addJobTitle": "Add / Edit Job",
"desktop.dialog.jobType": "Job Type:",
"desktop.dialog.agentWakeup": "Agent Wakeup (Periodic Execution)",
"desktop.dialog.toolJob": "Tool Job (Tool-specific Job)",
"desktop.dialog.jobTypeId": "Job Type ID / Name:",
"desktop.dialog.taskMessage": "Task Description / Message:",
"desktop.dialog.cronExpression": "Cron Expression:",
"desktop.dialog.cronHint": "Syntax: Min Hour Day Month Weekday (e.g. '*/15 * * * *' every 15 minutes)",
"desktop.dialog.runOnStart": "Run once immediately on application startup",
"desktop.dialog.addServiceTitle": "Add Service",
"desktop.dialog.serviceName": "Service Name:",
"desktop.dialog.serviceType": "Type:",
"desktop.dialog.servicePort": "Port:",
"desktop.dialog.serviceDesc": "Description:",
"desktop.dialog.restoreTitle": "Restore Backup",
"desktop.dialog.backupInfo": "Backup Information",
"desktop.dialog.backupInstance": "Instance: {0}",
"desktop.dialog.backupCreated": "Created: {0:g}",
"desktop.dialog.backupFiles": "Files: {0}",
"desktop.dialog.backupSecrets": "Credentials: {0}",
"desktop.dialog.targetDir": "Target Directory:",
"desktop.dialog.chooseFolder": "Choose Folder",
"desktop.dialog.restoreHint": "Note: It is recommended to restore into a new folder to avoid conflicts with running processes.",
"desktop.dialog.passphrase": "Passphrase:",
"desktop.dialog.passphraseProtected": "This backup contains protected credentials.",
"desktop.dialog.overwriteExisting": "Overwrite existing files in target folder",
"desktop.dialog.licenseTitle": "ClawdDotNet – License",
"desktop.dialog.licenseKey": "License Key",
"desktop.dialog.hardwareId": "Hardware ID (for support):",
"desktop.dialog.verify": "Verify",
"desktop.dialog.textEditorTitle": "Edit Text",
"desktop.instancePicker.titleSwitch": "Switch Instance",
"desktop.instancePicker.titleSelect": "Choose Instance",
"desktop.instancePicker.hintSwitch": "Each instance runs independently with its own agents and data. Switching restarts ClawdDotNet with the selected instance.",
"desktop.instancePicker.hintSelect": "Each instance runs independently with its own agents and data.",
"desktop.instancePicker.confirmSwitch": "Switch",
"desktop.instancePicker.confirmStart": "Start",
"desktop.instancePicker.newInstancePlaceholder": "Name of new instance",
"desktop.instancePicker.create": "Create",
"desktop.instancePicker.active": "Active",
"desktop.instancePicker.dirLabel": "Directory: {0}",
"desktop.instancePicker.agentsCount": "{0} Agents · API Key {1}",
"desktop.instancePicker.apiKeyConfigured": "configured",
"desktop.instancePicker.apiKeyMissing": "missing",
"desktop.instancePicker.tooltipConfigured": "API key configured",
"desktop.instancePicker.tooltipMissing": "No API key — agents remain inactive",
"desktop.instancePicker.nameRequired": "Please enter an instance name.",
"desktop.instancePicker.createFailed": "Instance could not be created: {0}",
"desktop.startMode.title": "ClawdDotNet – Select Start Mode",
"desktop.startMode.question": "How would you like to start ClawdDotNet?",
"desktop.startMode.managerBtn": "Launch Instance Manager (Recommended)",
"desktop.startMode.standaloneBtn": "Start as Standalone Instance",
"desktop.startMode.explanation": "• Instance Manager (Recommended):\n Starts the central background manager and opens management in your browser. Enables stable parallel operation of multiple instances without crash risks.\n\n• Standalone Mode:\n Starts exactly one instance directly as a single desktop window.",
"desktop.standaloneSwitch.title": "ClawdDotNet – Instance Switch (Standalone)",
"desktop.standaloneSwitch.warning": "Attention: Standalone Mode",
"desktop.standaloneSwitch.text": "This instance runs standalone without the Instance Manager.\n\nIf you switch instances here, this instance will be fully stopped and all running agents and background services will end.\n\nRecommendation: Launch the Instance Manager instead to run multiple instances concurrently and independently.",
"desktop.standaloneSwitch.switchAnyway": "Switch Instance Anyway",
"desktop.toolSettings.noSettings": "This tool does not require additional settings.",
"desktop.toolSettings.host": "Host",
"desktop.toolSettings.port": "Port",
"desktop.toolSettings.username": "Username",
"desktop.toolSettings.password": "Password",
"desktop.toolSettings.rootPath": "Root Path",
"desktop.toolSettings.imapHost": "IMAP Host",
"desktop.toolSettings.imapPort": "IMAP Port",
"desktop.toolSettings.smtpHost": "SMTP Host",
"desktop.toolSettings.smtpPort": "SMTP Port",
"desktop.toolSettings.allowedRecipients": "Allowed Recipients",
"desktop.toolSettings.type": "Type",
"desktop.toolSettings.connectionString": "Connection String",
"desktop.toolSettings.accessLevel": "Access Level",
"desktop.toolSettings.allowedTables": "Allowed Tables",
"desktop.toolSettings.botToken": "Bot Token",
"desktop.toolSettings.defaultChatId": "Default Chat ID",
"desktop.toolSettings.allowedChatIds": "Allowed Chat IDs",
"desktop.toolSettings.defaultProvider": "Default Provider",
"desktop.toolSettings.cacheTtl": "Cache TTL (seconds)",
"desktop.toolSettings.twelveDataKey": "Twelve Data Key",
"desktop.toolSettings.alphaVantageKey": "Alpha Vantage Key",
"desktop.toolSettings.allowedDomains": "Allowed Domains",
"desktop.toolSettings.maxResponseKb": "Max Response Size (KB)",
"desktop.toolSettings.userAgent": "User Agent",
"desktop.toolSettings.xApiKey": "X (Twitter) API Key",
"desktop.toolSettings.xAccounts": "X Accounts",
"desktop.toolSettings.subreddits": "Subreddits",
"desktop.toolSettings.redditPostLimit": "Reddit Post Limit",
"desktop.toolSettings.openRouterKey": "OpenRouter Key",
"desktop.toolSettings.sttModel": "STT Model",
"desktop.toolSettings.youtubeChannels": "YouTube Channels",
"desktop.toolSettings.fileRwPersonalExt": "Allowed File Extensions (personal)",
"desktop.toolSettings.fileRwSharedAccess": "Access Level (shared)",
"desktop.toolSettings.fileRwSharedExt": "Allowed File Extensions (shared)",
"desktop.toolSettings.fileRwProtectedPaths": "Protected Paths",
"nav.chat": "Chat",
"nav.agents": "Agents",
"nav.tasks": "Tasks",
"nav.usage": "Token Usage",
"nav.agentChats": "Agent Chats",
"nav.backup": "Backup",
"nav.logs": "Logs",
"nav.settings": "Settings",
"nav.info": "Info",
"common.all": "All",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.refresh": "Refresh",
"common.close": "Close",
"common.discard": "Discard",
"common.ready": "Ready",
"common.loading": "Loading…",
"common.create": "Create",
"common.add": "Add",
"common.apply": "Apply",
"common.ok": "OK",
"common.exit": "Exit"
}
-19
View File
@@ -164,25 +164,6 @@ internal static class ManagerEndpoints
};
});
// Der Wechsel, wie E-M2 ihn meint: Alle laufen, eine kommt nach vorn. Frueher
// hiess "Instanz wechseln", dass sich der laufende Prozess herunterfaehrt und mit
// einer anderen Instanz neu startet - wer wechselte, beendete zwangslaeufig die
// Instanz, aus der heraus er es tat.
verwaltung.MapPost("/instanzen/{id}/fenster", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
return await manager.FocusInstanceAsync(instanz.Directory, ct)
? Results.Ok(new OutcomeDto(true, $"Fenster von '{instanz.Name}' nach vorn geholt."))
: Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' hat kein Fenster nach vorn zu holen — "
+ "sie läuft nicht oder läuft kopflos."),
statusCode: StatusCodes.Status409Conflict);
});
verwaltung.MapPost("/instanzen/{id}/stop", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
@@ -781,7 +781,6 @@
"instances.selectActive": "Als aktiv wählen",
"instances.start": "Starten",
"instances.stop": "Beenden",
"instances.bringToFront": "Fenster nach vorn",
"instances.createTitle": "Neue Instanz anlegen",
"instances.instanceOfflineError": "Die Instanz ist nicht erreichbar.",
"instances.noInstanceActiveError": "Keine Instanz aktiv. Unter 'Instanzen' starten oder auswählen.",
@@ -781,7 +781,6 @@
"instances.selectActive": "Select as active",
"instances.start": "Start",
"instances.stop": "Stop",
"instances.bringToFront": "Bring window to front",
"instances.createTitle": "Create New Instance",
"instances.instanceOfflineError": "The instance is not reachable.",
"instances.noInstanceActiveError": "No instance is active. Start or select one under 'Instances'.",
@@ -176,19 +176,7 @@ async function ladeInstanzen(istHintergrund = false) {
erstelleElement("span", { text: " " + t("instances.stop") })
);
const fensterBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
disabled: !istLaufend,
title: t("instances.bringToFront"),
onclick: () => holeFensterNachVorn(instanz)
},
erstelleIcon(PFADE.fenster, 14),
erstelleElement("span", { text: " " + t("instances.bringToFront") })
);
fussAktionen.append(waehlenBtn, startenBtn, anhaltenBtn, fensterBtn);
fussAktionen.append(waehlenBtn, startenBtn, anhaltenBtn);
const karte = erstelleKarte(instanz.name, [dl, fussAktionen], {
aktionen: karteKopfAktionen,
@@ -336,17 +324,6 @@ function oeffneSicherheitsStoppDialog(instanz) {
modal.oeffnen();
}
async function holeFensterNachVorn(instanz) {
try {
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/fenster`, {
method: "POST"
});
zeigeMeldung(antwort?.meldung || `Brought '${instanz.name}' to front.`, "info");
} catch (err) {
zeigeMeldung(`Window focus failed: ${err.message}`, "warnung");
}
}
function oeffneNeueInstanzDialog() {
const formular = erstelleElement("form", {
klasse: "formular-raster",
@@ -58,14 +58,6 @@ public interface IInstanceChannel
/// Socket.</para>
/// </summary>
Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
/// <summary>
/// Bittet die Instanz, ihr Fenster nach vorn zu holen (E-M2).
///
/// <para><c>false</c> heißt: Die Bitte kam nicht an, oder diese Instanz hat kein
/// Fenster — sie läuft kopflos. Beides ist kein Fehler, sondern eine Auskunft.</para>
/// </summary>
Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default);
}
/// <summary>
@@ -135,10 +127,6 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
}
}
public Task<bool> RequestFocusAsync(
string instanceDirectory, CancellationToken ct = default)
=> PostAsync(instanceDirectory, "/api/instanz/fenster", ct);
public Task<bool> RequestStopAsync(
string instanceDirectory, CancellationToken ct = default)
=> PostAsync(instanceDirectory, "/api/instanz/beenden", ct);
+5 -2
View File
@@ -56,11 +56,14 @@ public interface IInstanceLauncher
/// Auf einer Maschine ohne Bildschirmsitzung gibt es keines, und dann ist kopflos die
/// einzige Betriebsart, die trägt.</para>
///
/// <para>#764: Seit die Fenster entfernt sind, ist kopflos die einzige Betriebsart —
/// <c>--headless</c> geht immer mit.</para>
///
/// <para><b><c>--managed</c> geht immer mit.</b> Daran erkennt die Instanz, dass sie
/// geführt wird, und bietet den Instanzwechsel nicht mehr selbst an: Der wäre ein
/// Selbst-Neustart und nähme genau die Instanz mit, aus der heraus jemand wechselt.</para>
/// </summary>
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
public sealed class ProcessInstanceLauncher(string executablePath)
: IInstanceLauncher
{
/// <summary>Wo die Programmdatei liegt — in der Regel neben der des Managers.</summary>
@@ -87,7 +90,7 @@ public sealed class ProcessInstanceLauncher(string executablePath, bool headless
CreateNoWindow = true
};
if (headless) start.ArgumentList.Add("--headless");
start.ArgumentList.Add("--headless");
start.ArgumentList.Add("--managed");
start.ArgumentList.Add("--instance");
@@ -215,28 +215,6 @@ public sealed class InstanceRegistry : IAsyncDisposable
public Task<bool> StopAsync(string instanceDirectory, CancellationToken ct = default)
=> Find(instanceDirectory).StopAsync(ct);
/// <summary>
/// Holt das Fenster einer Instanz nach vorn — der Wechsel, wie E-M2 ihn meint.
///
/// <para><b>Das ist der Ersatz für den Selbst-Neustart.</b> Vorher hiess „Instanz
/// wechseln": Der laufende Prozess fährt sich herunter und startet sich mit einer
/// anderen Instanz neu — wer wechselte, beendete zwangsläufig die Instanz, aus der
/// heraus er es tat. Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
///
/// <para><c>false</c> heisst: Sie läuft nicht, sie hört nicht, oder sie hat kein
/// Fenster.</para>
/// </summary>
public async Task<bool> FocusAsync(string instanceDirectory, CancellationToken ct = default)
{
var aufsicht = Find(instanceDirectory);
if (aufsicht.Condition != InstanceCondition.Running)
return false;
return await _channel.RequestFocusAsync(aufsicht.InstanceDirectory, ct)
.ConfigureAwait(false);
}
/// <summary>
/// Hält alle laufenden Instanzen an. <b>Nicht</b> beim Beenden des Managers, sondern
/// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf.
-3
View File
@@ -298,9 +298,6 @@ public sealed class ManagerCore : IAsyncDisposable
public Task<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.StopAsync(instanceDirectory, ct);
/// <inheritdoc cref="InstanceRegistry.FocusAsync"/>
public Task<bool> FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.FocusAsync(instanceDirectory, ct);
// ─── Das Urteil ───
@@ -47,6 +47,11 @@ public sealed class ManagerSettings
/// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier <c>false</c> hin;
/// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
/// bedienen.</para>
///
/// <para><b>#764: wird nicht mehr ausgewertet.</b> Die Avalonia-Fenster sind entfernt;
/// jede Instanz läuft kopflos. Der Schlüssel bleibt lesbar, damit eine bestehende
/// <c>manager.json</c> ihn nicht als unbekannt mitschleppt oder beim Laden scheitert —
/// gelöscht wird er beim nächsten Speichern nicht, er tut nur nichts mehr.</para>
/// </summary>
[JsonPropertyName("instanceWindows")]
public bool InstanceWindows { get; set; } = true;
+2 -3
View File
@@ -73,8 +73,7 @@ internal static class Program
: settings.Settings.InstanceExecutable);
logger.LogInformation(
"Instanzen laufen {Art}. Neustart nach Absturz: hoechstens {Grenze} in {Fenster}.",
settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster",
"Instanzen laufen ohne Fenster. Neustart nach Absturz: hoechstens {Grenze} in {Fenster}.",
settings.Settings.ToRestartPolicy().MaxRestarts,
settings.Settings.ToRestartPolicy().Window);
@@ -166,7 +165,7 @@ internal static class Program
Path.GetFullPath(s.InstancesDirectory));
var launcher = new ProcessInstanceLauncher(
ResolveInstanceExecutable(s, logger), headless: !s.InstanceWindows);
ResolveInstanceExecutable(s, logger));
var channel = new SocketInstanceChannel();
var registry = new InstanceRegistry(
@@ -40,6 +40,9 @@ public sealed record PulsDto(
/// Der Endpunkt löst aus, der Wirtsprozess reagiert. Und wer reagiert, ist verschieden —
/// kopflos die Warteschleife in <c>HeadlessRun</c>, mit Fenster die
/// Avalonia-Anwendung.</para>
///
/// <para>#764: Seit die Avalonia-Fenster entfernt sind, ist <c>HeadlessRun</c> der einzige
/// Wirt. Mit den Fenstern ging auch „Fenster nach vorn holen" (E-M2): Es gibt keines mehr.</para>
/// </summary>
public sealed class InstanceControl
{
@@ -48,14 +51,6 @@ public sealed class InstanceControl
/// <summary>Der Manager bittet, diesen Prozess zu beenden.</summary>
public event Action? ShutdownRequested;
/// <summary>
/// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2).
///
/// <para>Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins
/// Leere, und das ist die richtige Antwort.</para>
/// </summary>
public event Action? FocusRequested;
/// <summary>
/// Meldet den Wunsch zu enden an. Ein zweiter Aufruf tut nichts — sonst stiesse er
/// mitten im Herunterfahren ein zweites Herunterfahren an.
@@ -69,11 +64,6 @@ public sealed class InstanceControl
return true;
}
/// <summary>
/// Holt das Fenster nach vorn. Beliebig oft — anders als beim Beenden ist die
/// Wiederholung hier der Normalfall: Jeder Wechsel auf diese Instanz ist einer.
/// </summary>
public void RequestFocus() => FocusRequested?.Invoke();
}
/// <summary>
@@ -83,8 +73,8 @@ public sealed class InstanceControl
/// Endpunkte nicht, auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Der Grund ist der Schnitt aus
/// #528 — die Lebensdauer einer Instanz gehört dem Instanzmanager. Eine Browsersitzung
/// soll den Prozess, der sie gerade bedient, nicht beenden und auch nicht sein Fenster
/// aufreissen können; der Manager soll es, und er kommt über den Socket.</para>
/// soll den Prozess, der sie gerade bedient, nicht beenden können (bis #764 auch nicht sein
/// Fenster aufreissen); der Manager soll es, und er kommt über den Socket.</para>
/// </summary>
internal static class ControlEndpoints
{
@@ -130,18 +120,5 @@ internal static class ControlEndpoints
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
return Results.Accepted(value: new { angenommen = true });
});
control.MapPost("/fenster", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))
return Results.NotFound();
// Hier ohne OnCompleted: Das Fenster nach vorn zu holen beendet nichts, die
// Antwort geht ohnehin raus. Und der Manager soll gleich erfahren, ob dieser
// Prozess ueberhaupt eines hat.
steuerung.RequestFocus();
return Results.Ok(new { angenommen = true });
});
}
}
+2 -2
View File
@@ -56,8 +56,8 @@ public sealed class InstanceHttpHost : IAsyncDisposable
/// <summary>
/// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden
/// (<c>POST /api/instanz/beenden</c>) oder sein Fenster nach vorn holen
/// (<c>POST /api/instanz/fenster</c>).
/// (<c>POST /api/instanz/beenden</c>). Bis #764 auch: sein Fenster nach vorn holen —
/// das Fenster gibt es nicht mehr.
///
/// <para>Der Wirtsprozess hängt sich hier an und tut es: kopflos beendet sich die
/// Warteschleife, mit Fenster reagiert die Avalonia-Anwendung. Diese Klasse tut es