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:
co-authored by
Claude Opus 5.5
parent
8ec7896417
commit
6d81693041
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 > 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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user