diff --git a/CLAUDE.md b/CLAUDE.md index 48ad4b8..2311621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ Umstellung des Bestands ist ein eigenes Vorhaben mit niedriger Priorität. | Dokument | Wofür | |---|---| -| [docs/Oberflaechen-Leitfaden.md](docs/Oberflaechen-Leitfaden.md) | **Eingefroren seit 2026-10-02:** Die Avalonia-Oberfläche wird nicht mehr gepflegt und zur Hülle um die WebUI umgebaut (#764). Neue Funktionen nur in API + WebUI | +| [docs/archiv/Oberflaechen-Leitfaden.md](docs/archiv/Oberflaechen-Leitfaden.md) | **Archiv:** Die Avalonia-Fenster sind mit #764 (Stufe 3) entfernt. `ClawdDotNet` ist Instanzprozess, MCP-Brücke und Starter; einzige Oberfläche ist die WebUI. Neue Funktionen nur in API + WebUI | | [docs/umsetzungsplaene/WebUI-Only-und-Englisch.md](docs/umsetzungsplaene/WebUI-Only-und-Englisch.md) | Der Umbau zur reinen WebUI und die Englisch-Komplettierung (#764) | | [docs/WebUI-Leitfaden.md](docs/WebUI-Leitfaden.md) | Arbeiten an der Weboberfläche (`ClawdDotNet.Web`, #494) — Schichtschnitt, API-Fläche, Sicherheitsvorgaben | | [docs/ToolDevelopmentGuide.md](docs/ToolDevelopmentGuide.md) | Ein neues Agenten-Tool bauen | diff --git a/deploy/publish.py b/deploy/publish.py index 944b8ba..da490d4 100644 --- a/deploy/publish.py +++ b/deploy/publish.py @@ -90,6 +90,15 @@ def publish(rid: str, out_dir: Path) -> None: ]) merge_manager(manager_dir, out_dir) + # #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in + # einem Repository, das auf der Zielmaschine niemand hat. + if rid.startswith("linux"): + units = out_dir / "systemd" + units.mkdir(exist_ok=True) + for unit in sorted((REPO / "deploy" / "systemd").glob("*.service")): + shutil.copy2(unit, units / unit.name) + print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/") + def merge_manager(src: Path, dst: Path) -> None: """Legt den Manager neben die Instanz. diff --git a/docs/Roadmap.md b/docs/Roadmap.md index c7558f3..9bf2ae2 100644 --- a/docs/Roadmap.md +++ b/docs/Roadmap.md @@ -406,7 +406,7 @@ Anwendung stattdessen in ihr eigenes Installationsverzeichnis — das ein Update überschreibt. Der Befund blockiert damit denselben Betrieb, für den #214 und #215 da sind, und muss vor ihnen weg. -Die Pflichtliste des [Oberflächen-Leitfadens](Oberflaechen-Leitfaden.md) führt genau +Die Pflichtliste des [Oberflächen-Leitfadens](archiv/Oberflaechen-Leitfaden.md) führt genau diesen Fall: „Keine relativen Pfade … zeigt unter Linux ins Leere." Vorgabe für die Umsetzung: Ein relativer Wert wird gegen `instance.WorkingDirectory` @@ -885,7 +885,7 @@ Wer einen Punkt anfasst, liest das zugehörige Dokument zuerst. | Dokument | Wofür | |---|---| -| [Oberflaechen-Leitfaden](Oberflaechen-Leitfaden.md) | Arbeiten an `ClawdDotNet.Desktop`: Schichtschnitt, Fäden, Avalonia-12-Fallen, Prüfliste. Die Oberfläche hat **keine** Testabdeckung — Abschnitt 4 ist deshalb Pflichtlektüre vor jeder Abgabe | +| [Oberflaechen-Leitfaden](archiv/Oberflaechen-Leitfaden.md) | **Archiv seit #764** (Fenster entfernt). Damals: Arbeiten an `ClawdDotNet.Desktop`: Schichtschnitt, Fäden, Avalonia-12-Fallen, Prüfliste. Die Oberfläche hat **keine** Testabdeckung — Abschnitt 4 ist deshalb Pflichtlektüre vor jeder Abgabe | | [ToolDevelopmentGuide](ToolDevelopmentGuide.md) | Ein neues Agenten-Tool bauen | | [InstanceSetupGuide](InstanceSetupGuide.md) | Eine Instanz einrichten | | [Teststrategie](Teststrategie.md) | Was wie getestet wird, Fallkatalog | diff --git a/docs/WebUI-Leitfaden.md b/docs/WebUI-Leitfaden.md index 6001679..d2bb5ea 100644 --- a/docs/WebUI-Leitfaden.md +++ b/docs/WebUI-Leitfaden.md @@ -4,7 +4,7 @@ Für die zweite Oberfläche von ClawdDotNet: eine Weboberfläche im Browser, neb der Avalonia-Oberfläche und gleichzeitig mit ihr. Geschrieben für **Gemini 3.8 Flash**, das die Oberfläche baut. Stand: 2026-09-06. -Gegenstück zum [Oberflächen-Leitfaden](Oberflaechen-Leitfaden.md), der für +Gegenstück zum [Oberflächen-Leitfaden](archiv/Oberflaechen-Leitfaden.md) (seit #764 im Archiv), der für Avalonia gilt. Vorhaben im Deploymentcenter: diff --git a/docs/Oberflaechen-Leitfaden.md b/docs/archiv/Oberflaechen-Leitfaden.md similarity index 97% rename from docs/Oberflaechen-Leitfaden.md rename to docs/archiv/Oberflaechen-Leitfaden.md index d46d122..803a050 100644 --- a/docs/Oberflaechen-Leitfaden.md +++ b/docs/archiv/Oberflaechen-Leitfaden.md @@ -1,5 +1,9 @@ # Oberfläche — Leitfaden +> **Archiv seit 2026-10-02 (#764, Stufe 3).** Die Avalonia-Fenster, für die dieser Leitfaden +> galt, sind entfernt. Die einzige Oberfläche ist die WebUI ([WebUI-Leitfaden](../WebUI-Leitfaden.md)). +> Aufbewahrt wegen der Begründungen darin. + Für alle, die an `src/ClawdDotNet.Desktop` arbeiten. Stand: 2026-08-23. diff --git a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md index 218826f..2d35668 100644 --- a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md +++ b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md @@ -134,6 +134,17 @@ Probelauf am selben Tag mit `CefGlue.Next.Avalonia` 152.7977.831 (CEF 152, net10 ### Stufe 3 — Avalonia-Fenster entfernen (Flash: mit Vorsicht) +**Stand 2026-10-02: erledigt, vor Stufe 2 gezogen.** Der Mensch hat so entschieden, weil CEF +Avalonia 11.3 braucht und der Instanzprozess gar kein Avalonia mehr laden soll. Vorher +nachgezogen, weil Stufe 0 sie übersehen hatte und es sie nur im Fenster gab: +Telegram-Client-Anmeldung (#794) und Update anbieten/einspielen (#795, im Manager; unter systemd +über `clawddotnet-update.service`). `ClawdDotNet` ohne Schalter startet jetzt den Manager (unter +systemd über den Dienst, nie doppelt) und öffnet die WebUI im Systembrowser, bis die Hülle aus +Stufe 2 das übernimmt. `instanceWindows` in `manager.json` wird gelesen, aber nicht mehr +ausgewertet; die Kette „Fenster nach vorn holen" ist entfernt. Das Linux-Paket hat 38,8 MB, +mit `systemd/`-Vorlagen, und ist auf der VM Ende-zu-Ende geprüft (Instanz anlegen, kopflos +starten, anhalten). + - `Views/`, `Controls/`, `Converters/`, ViewModels der Fenster, `App.axaml` — weg. - Avalonia-Pakete aus der `.csproj`, soweit die Hülle sie nicht braucht. - `Program.cs`: ohne `--headless`/`--managed`/MCP-Brücke startet künftig die Hülle. @@ -264,3 +275,5 @@ Stand 2026-10-02, erhoben gegen `main` (c03d6d0). Quelle je Zeile: die `[RelayCo | L9 | Agent „jetzt laufen lassen" | nein, Endpunkt da | nein | | L10 | Automatische Sicherung einstellen | ja | nein | | L11 | Sicherung hochladen / als neue Instanz zurückspielen — vom Menschen am 2026-10-02 für Stufe 1 bestätigt | ja | Upload → ja | +| L12 | Telegram-Client-Anmeldung (Code, 2FA) — in Stufe 0 übersehen, gefunden in Stufe 3 | ja → **#794** | ja (Zugangsdaten) | +| L13 | Update anbieten und einspielen — in Stufe 0 übersehen, gefunden in Stufe 3 | ja → **#795** | Update-Agent → ja | diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 97e7365..43de8c7 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -306,14 +306,10 @@ public sealed class AppHost : IAsyncDisposable /// public bool IsManaged { get; init; } - /// - /// 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. - /// - public Func>? TelegramLogin { get; init; } - - public Func>? 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. } /// @@ -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) + /// + /// #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. + /// + 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(); } /// diff --git a/src/ClawdDotNet.Desktop/App.axaml b/src/ClawdDotNet.Desktop/App.axaml deleted file mode 100644 index 4b234d1..0000000 --- a/src/ClawdDotNet.Desktop/App.axaml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs deleted file mode 100644 index 4102e1d..0000000 --- a/src/ClawdDotNet.Desktop/App.axaml.cs +++ /dev/null @@ -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 -{ - /// - /// 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. - /// - private const string InstanceArgument = CommandLine.InstanceArgument; - - private AppHost? _host; - - /// Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null. - private InstanceHttpHost? _web; - - /// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht. - private string? _preselectedInstancePath; - - /// - /// Vom Instanzmanager gestartet (--managed). - /// - /// 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. - /// - 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; - - /// - /// SIGTERM und SIGINT beenden auch mit Fenster geordnet (#576). - /// - /// 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. - /// - 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(); - } - - /// - /// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint. - /// - /// 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. - /// - /// Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und - /// schreibt allenfalls beim allerersten Start die Vorgaben fest. - /// - 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. - } - } - - /// - /// Setzt die gespeicherte Sprache, bevor das erste Fenster erscheint. - /// - 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); - } - - /// - /// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. - /// - /// Erst hier verdrahtet, nicht in Main: 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 während des - /// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll. - /// - 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; - }; - } - - /// Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage. - 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)); - }; - } - - /// - /// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll. - /// - /// 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. - /// - 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); - } - - /// - /// Die Reihenfolge ist der ganze Punkt dieser Methode. - /// - /// - /// Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das - /// Verschwinden der Instanz aus wie ein Ausfall. - /// Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung. - /// Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet, - /// bis wir weg sind, statt über gesperrte Dateien zu kopieren. - /// Selbst beenden. - /// - /// - /// Die Verlockung wäre, LaunchUpdateAgent das Beenden zu überlassen — - /// das tut es aber über Environment.Exit und übergeht damit Schritt 2 - /// vollständig. - /// - 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()); - } - - /// Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit. - private static async Task ConfirmAsync( - string title, string message, string confirmText, string cancelText) - => await Dispatcher.UIThread.InvokeAsync(async () => - { - var completion = new TaskCompletionSource(); - - 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; - }); - - /// - /// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers. - /// - /// Die Instanz macht das selbst, statt dass jemand von aussen an ihrem - /// Fenster zieht. 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. - /// - /// Das letzte Wort hat der Fenstermanager. Windows lässt einen Prozess - /// im Hintergrund den Vordergrund nicht einfach an sich reissen — Activate - /// blinkt dann nur in der Leiste. Der kurze Ausflug über Topmost 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. - /// - 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(); - }); - - /// - /// Instanzwechsel aus der laufenden Anwendung. - /// - /// Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine - /// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an - /// einer Instanz. Statt das umzubauen startet sich die Anwendung mit - /// --instance neu — derselbe Weg, den auch das Update geht. - /// - /// Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess - /// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank. - /// - private enum StartModeChoice - { - LaunchManager, - Standalone, - Cancel - } - - private enum StandaloneSwitchChoice - { - LaunchManager, - SwitchStandalone, - Cancel - } - - /// - /// Instanzwechsel aus der laufenden Anwendung. - /// - 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}"); - } - } - - /// - /// Sucht die Programmdatei des Instanzmanagers. - /// - /// Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette. Hier - /// stand @"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0" — unter Linux, - /// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit - /// Rückstrichen darin, und die Suche ging dort nie auf. - /// - /// Die Entwicklungspfade bleiben, weil im Betrieb beide Programme - /// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei - /// vergebliche File.Exists. - /// - 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; - } - - /// - /// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet. - /// - /// Aus manager.json 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. - /// - 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 AskStandaloneSwitchModeAsync(Window? owner) - => await Dispatcher.UIThread.InvokeAsync(async () => - { - var completion = new TaskCompletionSource(); - 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 AskStartModeAsync() - => await Dispatcher.UIThread.InvokeAsync(async () => - { - var completion = new TaskCompletionSource(); - 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; - - /// - /// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat. - /// - /// Erst auslaufen lassen, dann abschneiden. Laufende Agenten und - /// beanspruchte Aufgaben bekommen ihre Frist - /// (), bevor Weboberfläche und Aufbau fallen. Ohne - /// das endete eine Aufgabe mitten in ProcessAsync und bliebe bis zum Ablauf - /// ihres Lease auf running stehen. - /// - /// Und nicht im Rückruf von desktop.ShutdownRequested. Avalonia - /// wartet dort nicht auf async-Behandlungen; was länger dauert als ein - /// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst - /// aufräumen und dann Shutdown rufen — der Rückruf dort bleibt als - /// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts. - /// - 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()); - } - - /// - /// 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. - /// - private async Task ShutdownWebAsync() - { - if (_web is null) return; - - var web = _web; - _web = null; - await web.DisposeAsync(); - } - - /// - /// Instanzauswahl beim Start. - /// - private async Task 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 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; - }); - - /// - /// Einzeilige Abfrage — ersetzt Microsoft.VisualBasic.Interaction.InputBox, - /// das die Telegram-Anmeldung an Windows band. - /// - private static async Task AskAsync(string title, string prompt) - => await Dispatcher.UIThread.InvokeAsync(async () => - { - var completion = new TaskCompletionSource(); - - 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; - }); -} diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Bold.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Bold.ttf deleted file mode 100644 index 2f20cbe..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Bold.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Medium.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Medium.ttf deleted file mode 100644 index d546e7a..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Medium.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Regular.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Regular.ttf deleted file mode 100644 index bb3faec..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-Regular.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-SemiBold.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-SemiBold.ttf deleted file mode 100644 index 7c974a4..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/Barlow-SemiBold.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Medium.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Medium.ttf deleted file mode 100644 index eee49eb..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Medium.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Regular.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Regular.ttf deleted file mode 100644 index ecbe0cc..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-Regular.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-SemiBold.ttf b/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-SemiBold.ttf deleted file mode 100644 index 9df6657..0000000 Binary files a/src/ClawdDotNet.Desktop/Assets/Fonts/BarlowCondensed-SemiBold.ttf and /dev/null differ diff --git a/src/ClawdDotNet.Desktop/Assets/Fonts/OFL.txt b/src/ClawdDotNet.Desktop/Assets/Fonts/OFL.txt deleted file mode 100644 index 2f22ba6..0000000 --- a/src/ClawdDotNet.Desktop/Assets/Fonts/OFL.txt +++ /dev/null @@ -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. diff --git a/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj index d26a64b..309d1ef 100644 --- a/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj +++ b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj @@ -9,32 +9,14 @@ ClawdDotNet Assets\app.ico - - true - + 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. --> false - - - - - - - - - - - - - - - + @@ -42,13 +24,6 @@ - - - - PreserveNewest - - - @@ -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. --> diff --git a/src/ClawdDotNet.Desktop/Controls/BlueprintFrame.cs b/src/ClawdDotNet.Desktop/Controls/BlueprintFrame.cs deleted file mode 100644 index 0f79b59..0000000 --- a/src/ClawdDotNet.Desktop/Controls/BlueprintFrame.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Controls.Primitives; - -namespace ClawdDotNet.Desktop.Controls; - -/// -/// Der Rahmen mit den vier Eckmarken — das wiederkehrende Motiv des Entwurfs. -/// -/// Eigenes Steuerelement statt vier von Hand gesetzter Striche je Stelle: Die Marken -/// liegen bewusst halb außerhalb des Rahmens, und das ist genau die Sorte Detail, -/// die beim Nachbauen an der fünften Stelle nicht mehr stimmt. -/// -/// 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. -/// -/// Sparsam verwenden. Im Entwurf tragen nur die Info-Karte und die beiden Dialoge -/// die Marken; alle übrigen Karten sind schlichte Haarlinien-Kästen (Klasse -/// card in Styles/Shell.axaml). -/// -public sealed class BlueprintFrame : ContentControl -{ - protected override Type StyleKeyOverride => typeof(BlueprintFrame); -} diff --git a/src/ClawdDotNet.Desktop/Controls/StrokeIcon.cs b/src/ClawdDotNet.Desktop/Controls/StrokeIcon.cs deleted file mode 100644 index 5b31636..0000000 --- a/src/ClawdDotNet.Desktop/Controls/StrokeIcon.cs +++ /dev/null @@ -1,113 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Documents; -using Avalonia.Media; - -namespace ClawdDotNet.Desktop.Controls; - -/// -/// Ein Strichsymbol aus dem Entwurf — 24×24-Geometrie, gezeichnet mit der geerbten -/// Vordergrundfarbe. -/// -/// Warum nicht PathIcon: Das füllt seine Geometrie. Die Symbole des Entwurfs -/// sind Umrisse (Lucide, Strichstärke 1.5); gefüllt werden daraus schwarze Klumpen. -/// -/// Warum nicht Path mit Stroke: Dann müsste an jeder einzelnen Stelle -/// die Farbe mitgegeben werden — und zwar die richtige, je nachdem ob das Symbol -/// in einer Schaltfläche, einer ausgewählten Navigationszeile oder der Statusleiste sitzt. -/// ist hier von übernommen und erbt -/// daher wie eine Textfarbe: Wer den Text einfärbt, färbt das Symbol mit. -/// -/// Die Strichstärke wird beim Skalieren mitskaliert. Das ist keine Nachlässigkeit, -/// sondern das Verhalten von SVG: stroke-width zählt in Einheiten des viewBox. -/// Ein Symbol mit 1.5 in 24×24, dargestellt auf 16 px, hat genau 1 px starke Linien. -/// -public sealed class StrokeIcon : Control -{ - /// Kantenlänge des Koordinatenraums aller Symbole (SVG-viewBox 0 0 24 24). - private const double ViewBox = 24d; - - public static readonly StyledProperty DataProperty = - AvaloniaProperty.Register(nameof(Data)); - - /// Geerbt wie eine Textfarbe — siehe Klassenbeschreibung. - public static readonly StyledProperty ForegroundProperty = - TextElement.ForegroundProperty.AddOwner(); - - public static readonly StyledProperty StrokeThicknessProperty = - AvaloniaProperty.Register(nameof(StrokeThickness), 1.5d); - - /// Für die wenigen massiven Symbole des Entwurfs (Wiedergabe, Halt). - public static readonly StyledProperty IsFilledProperty = - AvaloniaProperty.Register(nameof(IsFilled)); - - static StrokeIcon() - { - AffectsRender( - DataProperty, ForegroundProperty, StrokeThicknessProperty, IsFilledProperty); - AffectsMeasure(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); - } - - /// - /// 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. - /// - 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); - } -} diff --git a/src/ClawdDotNet.Desktop/Converters/IconConverter.cs b/src/ClawdDotNet.Desktop/Converters/IconConverter.cs deleted file mode 100644 index ffc25b7..0000000 --- a/src/ClawdDotNet.Desktop/Converters/IconConverter.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Globalization; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace ClawdDotNet.Desktop.Converters; - -/// -/// Schlägt eine Symbol-Geometrie aus Styles/Icons.axaml über ihren Namen nach. -/// -/// Damit halten die Ansichtsmodelle nur den Namen („IconChat") statt einer -/// — sie brauchen dann keinen Verweis auf Avalonia.Media, -/// und die Navigationsleiste kommt mit einer schlichten Zeichenkette je Eintrag aus. -/// -/// 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 Controls/StrokeIcon.cs). Für Farben wäre dieser Weg falsch — ein einmal -/// aufgelöster Pinsel bliebe beim Wechsel auf Dunkel stehen. -/// -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."); -} - -/// -/// Der Pfeil am Einklapp-Schalter der Seitenleiste: zeigt nach links, solange -/// eingeklappt werden kann, und nach rechts, wenn wieder aufgeklappt wird. -/// -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."); -} diff --git a/src/ClawdDotNet.Desktop/Converters/LayoutConverters.cs b/src/ClawdDotNet.Desktop/Converters/LayoutConverters.cs deleted file mode 100644 index d452b5f..0000000 --- a/src/ClawdDotNet.Desktop/Converters/LayoutConverters.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Globalization; -using Avalonia.Data.Converters; -using Avalonia.Layout; - -namespace ClawdDotNet.Desktop.Converters; - -/// -/// Richtet Sprechblasen aus: rechts fuer den Benutzer, links fuer den Agenten. -/// -/// 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 card accent. -/// -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."); -} - -/// -/// Prueft, ob ein Werkzeugname mit dem ConverterParameter uebereinstimmt. -/// Blendet im Werkzeug-Einstellungsbereich die passende Feldgruppe ein. -/// -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."); -} diff --git a/src/ClawdDotNet.Desktop/Converters/StringEqualsConverter.cs b/src/ClawdDotNet.Desktop/Converters/StringEqualsConverter.cs deleted file mode 100644 index 36930eb..0000000 --- a/src/ClawdDotNet.Desktop/Converters/StringEqualsConverter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Globalization; -using Avalonia.Data.Converters; - -namespace ClawdDotNet.Desktop.Converters; - -/// -/// Vergleicht eine Zeichenkette mit dem ConverterParameter, ohne Gross- und -/// Kleinschreibung zu unterscheiden. -/// -/// Gedacht fuer Classes.xyz="{Binding …}": Die Ansicht setzt damit eine -/// Stilklasse, und die Farbe dazu steht in Styles/Shell.axaml an -/// DynamicResource. 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. -/// -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."); -} diff --git a/src/ClawdDotNet.Desktop/LauncherRun.cs b/src/ClawdDotNet.Desktop/LauncherRun.cs new file mode 100644 index 0000000..3b35a4b --- /dev/null +++ b/src/ClawdDotNet.Desktop/LauncherRun.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Desktop; + +/// +/// #764: ClawdDotNet started without a switch — by a double click, a start menu entry, +/// a desktop file. +/// +/// 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. +/// +/// Never a second manager. 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. +/// +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 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; + } + + /// Why the manager could not be started, or null. + 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 + } + } + + /// + /// Next to this program in an installation; the build folders in a development checkout. + /// + /// 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 App.axaml.cs). + /// + 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; + } + + /// + /// The port from manager.json; the default on any doubt. Read as one field, not as + /// the manager's settings type, which this program does not know. + /// + 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; + } + } +} diff --git a/src/ClawdDotNet.Desktop/Program.cs b/src/ClawdDotNet.Desktop/Program.cs index b3fddfe..c0ba1e8 100644 --- a/src/ClawdDotNet.Desktop/Program.cs +++ b/src/ClawdDotNet.Desktop/Program.cs @@ -1,25 +1,21 @@ -using Avalonia; - namespace ClawdDotNet.Desktop; internal static class Program { /// - /// Einstiegspunkt der Oberfläche. + /// Einstiegspunkt. /// - /// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche — - /// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut - /// über die Anwendungsschicht auf, damit derselbe Aufbau später - /// auch ohne Fenster laufen kann. + /// #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 (--headless --managed + /// --instance …, 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 (). /// - /// Kein [STAThread] mehr: Das war eine COM-Anforderung von WinForms. Avalonia - /// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung. + /// Kein Fenstersystem wird angefasst — auf einem Server ohne Bildschirmsitzung gäbe + /// es keines (#528, Stufe 2). Das galt bisher für --headless und gilt jetzt für + /// alles. /// - /// - /// Die Verzweigung steht vor und nicht darin: Mit - /// --headless soll gar kein Fenstersystem angefasst werden — auf einem Server - /// ohne Bildschirmsitzung gäbe es keines (#528, Stufe 2). - /// 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); - - /// - /// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und - /// getrennt von . - /// - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() - .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(); } diff --git a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs deleted file mode 100644 index 573fcd7..0000000 --- a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// . Das ist der Grund, warum -/// durchgehend asynchron ist: Die WinForms-Fassung konnte -/// ShowDialog einfach blockierend aufrufen, weil sie ohnehin auf dem -/// Oberflächenfaden lief. -/// -public sealed class AvaloniaLicensePrompt : ILicensePrompt -{ - public async Task 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); - - /// - /// Ein Meldungsfenster. - /// - /// Avalonia bringt kein MessageBox 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. - /// - 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; - }); -} diff --git a/src/ClawdDotNet.Desktop/Services/LocalizationManager.cs b/src/ClawdDotNet.Desktop/Services/LocalizationManager.cs deleted file mode 100644 index c64bb3c..0000000 --- a/src/ClawdDotNet.Desktop/Services/LocalizationManager.cs +++ /dev/null @@ -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; -} - -/// -/// 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. -/// -public sealed class LocalizationManager : INotifyPropertyChanged -{ - public static LocalizationManager Instance { get; } = new(); - - public const string DefaultLanguage = "en"; - public const string FallbackLanguage = "en"; - - private readonly Dictionary _fallbackStrings = new(StringComparer.OrdinalIgnoreCase); - private Dictionary _activeStrings = new(StringComparer.OrdinalIgnoreCase); - private string _currentLanguage = DefaultLanguage; - - public IReadOnlyList 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(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 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 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)); - } -} diff --git a/src/ClawdDotNet.Desktop/Services/LocalizeExtension.cs b/src/ClawdDotNet.Desktop/Services/LocalizeExtension.cs deleted file mode 100644 index 2e89a57..0000000 --- a/src/ClawdDotNet.Desktop/Services/LocalizeExtension.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Avalonia.Data; -using Avalonia.Markup.Xaml; - -namespace ClawdDotNet.Desktop.Services; - -/// -/// Markup extension for Avalonia XAML: -/// Usage: Text="{services:Localize nav.chat}" -/// Updates automatically when language changes. -/// -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 - }; - } -} diff --git a/src/ClawdDotNet.Desktop/Services/ThemeManager.cs b/src/ClawdDotNet.Desktop/Services/ThemeManager.cs deleted file mode 100644 index 9ed475b..0000000 --- a/src/ClawdDotNet.Desktop/Services/ThemeManager.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Avalonia; -using Avalonia.Styling; - -namespace ClawdDotNet.Desktop.Services; - -/// -/// Hell/Dunkel umschalten — an genau einer Stelle. -/// -/// Die Farbwerte selbst stehen in Styles/Theme.axaml als -/// ThemeDictionaries. Hier wird nur umgelegt, welches der beiden Wörterbücher gilt; -/// jede Bindung mit DynamicResource 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. -/// -public static class ThemeManager -{ - /// Name in den Einstellungen, wenn dunkel gewählt ist. - public const string DarkName = "Dark"; - - /// Übersetzt den gespeicherten Namen; alles außer „Dark" gilt als hell. - public static bool IsDarkName(string? name) - => string.Equals(name, DarkName, StringComparison.OrdinalIgnoreCase); - - public static string ToName(bool isDark) => isDark ? DarkName : "Light"; - - /// - /// Legt die Variante an der Anwendung um. Ohne laufende Anwendung — etwa im - /// Entwurfsmodus des Editors — passiert nichts. - /// - public static void Apply(bool isDark) - { - if (Application.Current is not { } app) return; - app.RequestedThemeVariant = isDark ? ThemeVariant.Dark : ThemeVariant.Light; - } -} diff --git a/src/ClawdDotNet.Desktop/Styles/Icons.axaml b/src/ClawdDotNet.Desktop/Styles/Icons.axaml deleted file mode 100644 index 9848df3..0000000 --- a/src/ClawdDotNet.Desktop/Styles/Icons.axaml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - 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 - - - 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 - 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 - M3 17l2 2 4-4 M3 7l2 2 4-4 M13 6h8 M13 12h8 M13 18h8 - M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7 - 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 - 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 - 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 - 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 - M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0 M12 16v-4 M12 8h.01 - - - 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 - M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z - M15 18l-6-6 6-6 - M9 18l6-6-6-6 - M6 9l6 6 6-6 - - - 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 - M18 6L6 18 M6 6l12 12 - - - M12 5v14 M5 12h14 - M5 12h14 - M8 5v14l11-7z - M6 6h12v12H6z - M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z - 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 - 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 - 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 - M18.36 6.64a9 9 0 1 1-12.73 0 M12 2v10 - 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 - - diff --git a/src/ClawdDotNet.Desktop/Styles/Shell.axaml b/src/ClawdDotNet.Desktop/Styles/Shell.axaml deleted file mode 100644 index 17e7013..0000000 --- a/src/ClawdDotNet.Desktop/Styles/Shell.axaml +++ /dev/null @@ -1,862 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Styles/Theme.axaml b/src/ClawdDotNet.Desktop/Styles/Theme.axaml deleted file mode 100644 index a809100..0000000 --- a/src/ClawdDotNet.Desktop/Styles/Theme.axaml +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - - - - - - - - - - - - #f2f2f3 - #e9e9ea - #1d1f20 - #5980a6 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - #1a1c1e - #232527 - #eef0f1 - #7ea3c6 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - avares://ClawdDotNet/Assets/Fonts#Barlow Condensed - avares://ClawdDotNet/Assets/Fonts#Barlow - Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace - - diff --git a/src/ClawdDotNet.Desktop/ViewLocator.cs b/src/ClawdDotNet.Desktop/ViewLocator.cs deleted file mode 100644 index 9693908..0000000 --- a/src/ClawdDotNet.Desktop/ViewLocator.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Controls.Templates; -using ClawdDotNet.Desktop.ViewModels; - -namespace ClawdDotNet.Desktop; - -/// -/// Findet zu einem Ansichtsmodell die passende Ansicht über die Namenskonvention -/// …ViewModels.FooViewModel → …Views.FooView. -/// -/// Das erspart es, jede Zuordnung von Hand als DataTemplate 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. -/// -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; -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs deleted file mode 100644 index c10ef19..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs +++ /dev/null @@ -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 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(); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs deleted file mode 100644 index f070f11..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs +++ /dev/null @@ -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 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 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 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(); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs deleted file mode 100644 index 6b2f8fe..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs +++ /dev/null @@ -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 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(); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AgentChatsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AgentChatsPageViewModel.cs deleted file mode 100644 index 35b319a..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/AgentChatsPageViewModel.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Collections.ObjectModel; -using ClawdDotNet.App; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace ClawdDotNet.Desktop.ViewModels; - -/// Eine Nachricht zwischen zwei Agenten. -public sealed class AgentMessageItem -{ - public required string SenderName { get; init; } - public required string Text { get; init; } - public required DateTime Time { get; init; } - - /// Links steht Agent A, rechts Agent B — wie im Chat mit dem Benutzer. - public required bool IsFromAgentA { get; init; } -} - -/// Ein Agent in den beiden Auswahlfeldern. -public sealed record AgentChoice(string AgentId, string DisplayName) -{ - public override string ToString() => DisplayName; -} - -/// -/// Verlauf der Nachrichten zwischen zwei Agenten. -/// -/// Ohne Datenbestand. Agenten können sich heute schon Nachrichten -/// schicken — AgentEngine.SendMessageAsync stellt zu, aber nichts schreibt den -/// Wortlaut mit. Der Speicher AgentMessages aus -/// docs/Agentenkommunikation-Konzept.md ist noch nicht gebaut. -/// -/// 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 — -/// . Bis dahin erklärt die Seite ihren Leerzustand, statt als -/// leerer Kasten dazustehen. -/// -public sealed partial class AgentChatsPageViewModel : PageViewModel -{ - public ObservableCollection Agents { get; } = []; - public ObservableCollection 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(); - - /// - /// Hier kommt die Abfrage hin, sobald der AgentMessages-Speicher steht: alle - /// Nachrichten zwischen und in beiden - /// Richtungen, nach Zeit sortiert. - /// - private void LoadMessages() - { - Messages.Clear(); - - EmptyText = Agents.Count < 2 - ? LocalizationManager.Instance["desktop.agentChats.needTwo"] - : LocalizationManager.Instance["desktop.agentChats.empty"]; - - IsEmpty = Messages.Count == 0; - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs deleted file mode 100644 index ebbde54..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs +++ /dev/null @@ -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; } - - /// - /// 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. - /// - 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; - - /// #716: What happens to a recurring task's run when the budget is exhausted. - [ObservableProperty] - private string _whenExhausted = "expire"; - - /// The values understands. - public IReadOnlyList WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"]; - - public ObservableCollection AvailableTools { get; } = []; - - /// - /// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht - /// dieselbe, und zwei gepflegte Listen wären eine zu viel. - /// - public static IReadOnlyList KnownTools => AgentAdministration.KnownTools; - - public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary? 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(); - 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; - } -} - -/// Welches Feld der Texteditor gerade bearbeitet. -public enum AgentTextField -{ - Identity, - Soul -} - -/// -/// Ansichtsmodell für die Agenten-Verwaltungsseite. -/// -/// 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 an der -/// Auswahl in der Werkzeugliste. -/// -public sealed partial class AgentsPageViewModel : PageViewModel -{ - private readonly AppHost? _host; - private readonly ILogger? _logger; - - public ObservableCollection Agents { get; } = []; - - [ObservableProperty] - private AgentItemViewModel? _selectedAgent; - - /// - /// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten - /// rechts stehen. - /// - [ObservableProperty] - private ToolItemViewModel? _selectedTool; - - /// - /// 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. - /// - [ObservableProperty] - private ToolSettingsViewModel? _toolSettings; - - [ObservableProperty] - private string _statusText = "Bereit"; - - [ObservableProperty] - private bool _isRunning; - - public List 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 CachingOptions { get; } = ["auto", "on", "off"]; - - public event Func>? RequestAddAgentDialog; - - /// - /// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück, - /// wurde gespeichert; null heißt Abbruch. - /// - public event Func>? 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)); - } - - /// - /// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in - /// ist fest verdrahtet und kennt auch - /// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die - /// Beschreibung eben leer. - /// - private IReadOnlyDictionary ToolDescriptions() - => _host?.AgentAdmin.ToolDescriptions() ?? new Dictionary(); - - /// - /// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt. - /// - /// 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. - /// - [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(); - - 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(); - } - - /// - /// 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. - /// - [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); - - /// - /// 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. - /// - 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(); - } - - /// Öffnet den Arbeitsordner des gewählten Agenten. - [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); - } - - /// - /// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration. - /// - /// 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. - /// - [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"]; - } - - /// - /// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte. - /// - /// Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei - /// ApplyChanges 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. - /// - 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); - - /// - /// 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. - /// - 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. - } - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs deleted file mode 100644 index 92b362a..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs +++ /dev/null @@ -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 -); - -/// -/// Ansichtsmodell für die Sicherungs- und Wiederherstellungsseite. -/// -public sealed partial class BackupPageViewModel : PageViewModel -{ - private readonly BackupService _service = new(); - private readonly AppHost? _host; - - /// - /// 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. - /// - 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 Backups { get; } = []; - - public event Func>? 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(); - - 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" - }; -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs deleted file mode 100644 index e9db1bb..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs +++ /dev/null @@ -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 -); - -/// -/// Ansichtsmodell für die native Chat-Seite (Avalonia UI statt WebView2). -/// -public sealed partial class ChatPageViewModel : PageViewModel -{ - private readonly AppHost? _host; - - public ObservableCollection AvailableAgents { get; } = []; - public ObservableCollection 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."; - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs deleted file mode 100644 index e1c4aef..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs +++ /dev/null @@ -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); - -/// -/// Ansichtsmodell für die Info-Seite (Version, Build-Informationen, Instanz-Details). -/// -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 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(); - } - - /// - /// Fruehere Menueleiste, Punkt „Bearbeiten > Instanz-Ordner oeffnen". Der Entwurf - /// kennt keine Menueleiste mehr; der Befehl sitzt jetzt dort, wo auch der Pfad steht. - /// - [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() - .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)); - } - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs deleted file mode 100644 index 17fc056..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs +++ /dev/null @@ -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; - -/// -/// Eine Instanz in der Auswahl. -/// -/// Das Monogramm und der Punkt stammen aus dem Entwurf. Der Punkt zeigt bewusst -/// Bereitschaft 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. -/// -public sealed class InstanceChoiceViewModel(InstanceInfo info, bool isCurrent) -{ - public InstanceInfo Info { get; } = info; - - public string InstanceName => Info.InstanceName; - public string FolderPath => Info.FolderPath; - - /// Erste zwei Buchstaben, gross — wie im Entwurf. - 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); - } - } - - /// Ohne API-Key kann die Instanz keine Agenten starten. - public bool IsReady => Info.ApiKeyStatus.Equals("Konfiguriert", StringComparison.OrdinalIgnoreCase) - || Info.ApiKeyStatus.Equals("Configured", StringComparison.OrdinalIgnoreCase); - - /// Die gerade laufende Instanz — traegt im Entwurf das Etikett „Aktiv". - public bool IsCurrent { get; } = isCurrent; -} - -/// -/// Auswahl der Instanz — beim Start und beim Wechseln aus der laufenden Anwendung. -/// -/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster -/// in einem Formular. Hier steht nur, was geschieht; das wie es aussieht -/// liegt in der Ansicht, und Rückfragen laufen über statt über -/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar. -/// -public sealed partial class InstancePickerViewModel : ViewModelBase -{ - private readonly InstanceDirectoryManager _directories; - private readonly TaskCompletionSource _completion = new(); - private readonly string? _currentInstancePath; - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(StartCommand))] - private InstanceChoiceViewModel? _selected; - - [ObservableProperty] - private string _newInstanceName = ""; - - /// Fehlertext für die Ansicht; leer heißt: alles in Ordnung. - [ObservableProperty] - private string _errorText = ""; - - public ObservableCollection Instances { get; } = []; - - /// Der gewählte Pfad, oder null bei Abbruch. - public Task Result => _completion.Task; - - public string InstancesDirectory => _directories.InstancesDirectory; - - /// - /// Beim Start heisst die Schaltflaeche „Starten", aus der laufenden Anwendung heraus - /// „Wechseln" — und dann steht der Hinweis dabei, dass dafuer neu gestartet wird. - /// - public bool IsSwitchMode => _currentInstancePath is not null; - - public string ConfirmLabel => IsSwitchMode - ? LocalizationManager.Instance["desktop.instancePicker.confirmSwitch"] - : LocalizationManager.Instance["desktop.instancePicker.confirmStart"]; - - /// Ueberschrift des Dialogs — nicht aus ConfirmLabel zusammengesetzt, - /// sonst stuende dort „Instanz Starten". - 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"]; - - /// - /// Pfad der laufenden Instanz, oder null beim Start. Steuert Beschriftung und - /// das Etikett „Aktiv" in der Liste. - /// - 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); - - /// Waehlt eine Zeile und bestaetigt in einem Zug — die Schaltflaeche je Zeile. - [RelayCommand] - private void Activate(InstanceChoiceViewModel? choice) - { - if (choice is null) return; - Selected = choice; - _completion.TrySetResult(choice.FolderPath); - } - - /// Abbruch — der Aufrufer beendet die Anwendung oder schliesst den Dialog. - [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); - } - } - - /// - /// Wird gesetzt, wenn ein Fehler von außen kommt (etwa eine unlesbare - /// Instanzkonfiguration), damit das Fenster offen bleiben und eine andere Wahl - /// zulassen kann. - /// - public void ConfirmError(string message) => ErrorText = message; -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs deleted file mode 100644 index 5cdfad1..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs +++ /dev/null @@ -1,46 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace ClawdDotNet.Desktop.ViewModels; - -/// Eingabe eines Lizenzschlüssels. Siehe AvaloniaLicensePrompt. -public sealed partial class LicenseViewModel : ViewModelBase -{ - private readonly TaskCompletionSource _completion = new(); - - [ObservableProperty] - private string _key; - - public string HardwareId { get; } - - /// Warum der bisherige Schlüssel nicht taugt; leer beim ersten Fragen. - public string Problem { get; } - - /// Der eingegebene Schlüssel, oder null bei Abbruch. - public Task 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); - - /// Schließt der Benutzer das Fenster, gilt das als Abbruch. - public void CancelIfPending() => _completion.TrySetResult(null); -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs deleted file mode 100644 index a34f12c..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs +++ /dev/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; - -/// -/// Live-Ansicht der Logdateien. -/// -/// Der Takt läuft über — das Avalonia-Gegenstück zum -/// System.Windows.Forms.Timer, 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 -/// und taktet dort über PeriodicTimer. -/// -public sealed partial class LogPageViewModel : PageViewModel -{ - /// - /// 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. - /// - private const int MaxLines = 2000; - - private readonly LogTail? _tail; - private readonly DispatcherTimer? _timer; - private readonly string _logDirectory; - - public ObservableCollection Lines { get; } = []; - - public ObservableCollection Modules { get; } = [LocalizationManager.Instance["common.all"]]; - - public IReadOnlyList 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"]; - - /// - /// 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. - /// - [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); -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 8003868..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -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; - -/// -/// Das Gerüst des Hauptfensters: Seitenleiste, Kopfzeile, Meldungen, Statusleiste. -/// -/// 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. -/// -public sealed partial class MainWindowViewModel : ViewModelBase -{ - private readonly AppHost? _host; - - /// Breite der Seitenleiste, ausgeklappt und eingeklappt (siehe Entwurf). - 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); - - /// Kurzinfo zu Tokens und Kosten der letzten Stunde bzw. 24 Stunden. - [ObservableProperty] - private string _usageText = "—"; - - [ObservableProperty] - private string _usageTooltip = ""; - - [ObservableProperty] - private string _creditsText = "—"; - - /// Alle Bereiche, in der Reihenfolge der Navigation. - public ObservableCollection Pages { get; } = []; - - /// Die Navigation, gruppiert wie im Entwurf. - public ObservableCollection NavGroups { get; } = []; - - /// - /// Instanzübergreifende Hinweise. Bleibt vorerst leer — der Dienst, der Fehler und - /// aufgebrauchte Guthaben anderer 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. - /// - public ObservableCollection 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"]; - - /// Fusszeile der Seitenleiste. - public string VersionText => $"ClawdDotNet {AppHost.AppVersion} · Avalonia"; - - /// Sonne im Hellmodus, Mond im Dunkelmodus — beides Namen aus Icons.axaml. - public string ThemeIconKey => IsDarkTheme ? "IconMoon" : "IconSun"; - - public event Action? RequestExit; - public event Action? RequestInstanceManagerDialog; - - /// - /// Ob der Instanzwechsel aus diesem Fenster heraus angeboten wird. - /// - /// false, 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. - /// - public bool CanSwitchInstance { get; init; } = true; - - /// Für den Entwurfsmodus des Editors — ohne laufenden Aufbau. - 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); - } - - /// - /// Hängt die Statusleiste an den OpenRouter-Dienst. - /// - /// Der Dienst war fertig und ungenutzt — er lieferte seine Texte bislang an - /// niemanden. OnStatusUpdated kommt vom Hintergrundfaden des - /// PeriodicTimer, deshalb der Wechsel auf den Oberflächenfaden. - /// - 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)); - } - - /// - /// 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. - /// - private void ApplyCollapsedToNav() - { - foreach (var group in NavGroups) - { - group.IsTitleVisible = !IsSidebarCollapsed; - - foreach (var item in group.Items) - item.IsCollapsed = IsSidebarCollapsed; - } - } - - /// - /// 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. - /// - private void Persist(Action 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(); -} - -/// Eine Gruppe der Navigation („Arbeiten", „Analyse", „System"). -public sealed partial class NavGroupViewModel : ObservableObject -{ - private readonly string _titleKey; - public string Title => LocalizationManager.Instance.Get(_titleKey); - public IReadOnlyList Items { get; } - - /// Im eingeklappten Zustand entfällt die Gruppenüberschrift. - [ObservableProperty] - private bool _isTitleVisible = true; - - public NavGroupViewModel(string titleKey, IReadOnlyList items) - { - _titleKey = titleKey; - Items = items; - LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(Title)); - } -} - -/// -/// Ein Eintrag der Navigation. -/// -/// Der Befehl sitzt am Eintrag selbst und nicht am Fenster: Aus einer DataTemplate -/// heraus über $parent[…] 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. -/// -public sealed partial class NavItemViewModel : ObservableObject -{ - private readonly MainWindowViewModel _owner; - public PageViewModel Page { get; } - public string Title => Page.Title; - - /// Name der Geometrie in Styles/Icons.axaml. - 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; -} - -/// Eine Zeile der Benachrichtigungsleiste. -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); -} - -/// Ein Bereich des Hauptfensters. -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)); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs deleted file mode 100644 index d93bf37..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs +++ /dev/null @@ -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(); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs deleted file mode 100644 index adcca0a..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs +++ /dev/null @@ -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; - -/// -/// Ansichtsmodell für die Einstellungen-Seite (Anwendungs- und Instanz-Einstellungen). -/// -public sealed partial class SettingsPageViewModel : PageViewModel -{ - private readonly AppHost? _host; - private readonly ILogger? _logger; - - /// Womit die Seite geladen wurde — nur, um eine Änderung zu erkennen. - 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 = ""; - - /// - /// 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. - /// - /// 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. - /// - [ObservableProperty] - private string _logDirectory = ""; - - /// - /// Ob der Instanzmanager diese Instanz am Laufen hält (#528). - /// - /// 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. - /// - [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 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 LogLevels { get; } = ["Debug", "Info", "Warn", "Error"]; - public List UpdateChannels { get; } = ["prod", "beta", "dev"]; - public List 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; - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs deleted file mode 100644 index 9051afc..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs +++ /dev/null @@ -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 -); - -/// Die drei Ansichten der Aufgaben-Seite. -public enum TasksTab -{ - Jobs, - Services, - History -} - -public sealed record ServiceDisplayItemViewModel( - string ServiceId, - string Name, - string Type, - int Port, - string Status, - string Description -); - -/// -/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf). -/// -public sealed partial class TasksPageViewModel : PageViewModel -{ - private readonly AppHost? _host; - private readonly ILogger? _logger; - - public ObservableCollection JobEntries { get; } = []; - public ObservableCollection ServiceEntries { get; } = []; - public ObservableCollection JobHistoryEntries { get; } = []; - - [ObservableProperty] - private JobDisplayItemViewModel? _selectedJob; - - [ObservableProperty] - private ServiceDisplayItemViewModel? _selectedService; - - [ObservableProperty] - private string _statusText = "Bereit"; - - [ObservableProperty] - private bool _isRunningJob; - - /// - /// 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. - /// - [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>? RequestAddJobDialog; - public event Func>? 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); - } - - /// - /// Formt einen geplanten Lauf für die Anzeige um. - /// - /// Die Fachschicht liefert Zeitpunkte als ; 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. - /// - 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(); - } - - /// Übersetzt das Ergebnis des Dialogs in das, womit die Fachschicht arbeitet. - 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(); - } - - /// - /// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue - /// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste. - /// - /// Gilt nur für Tool-Jobs: SchedulerConfig 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. - /// - [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); - } - - /// - /// Aktiviert bzw. deaktiviert einen Dienst. - /// - /// Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt: - /// Umgelegt wird das Kennzeichen Enabled 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. - /// - [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 - }; - } - -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/TokenUsagePageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/TokenUsagePageViewModel.cs deleted file mode 100644 index b6068b9..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/TokenUsagePageViewModel.cs +++ /dev/null @@ -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; - -/// Ein Balken des Wochendiagramms. -public sealed class TokenDayItem -{ - public required string DayLabel { get; init; } - public required string TokensLabel { get; init; } - public required int Tokens { get; init; } - - /// - /// 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. - /// - public required double BarHeight { get; init; } -} - -/// Eine Zeile der Aufschlüsselung nach Agent und Modell. -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; } -} - -/// -/// Tokennutzung der letzten sieben Tage. -/// -/// Liest aus — 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. -/// -public sealed partial class TokenUsagePageViewModel : PageViewModel -{ - /// Höhe der Diagrammfläche in Bildpunkten (siehe Entwurf: 160 px inkl. Beschriftung). - private const double ChartHeight = 130; - - /// Wie viele Läufe für die Aufschlüsselung herangezogen werden. - private const int RecentRunLimit = 1000; - - private const int Days = 7; - - private readonly AppHost? _host; - - public ObservableCollection Days7 { get; } = []; - public ObservableCollection 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(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 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 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 daily, IReadOnlyList 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"] : ""; - } - - /// - /// Kosten in Euro, wie in der Statusleiste — mit demselben Kurs aus - /// . Das Zeichen ⚠ steht für Läufe, - /// deren Modellpreise nicht vorlagen: Die Summe ist dann zu niedrig, und das soll man - /// ihr ansehen. - /// - private static string FormatCost(decimal usd, bool incomplete) - { - var eur = (double)usd * OpenRouterStatusService.UsdToEur; - return incomplete ? $"{eur:F2} € ⚠" : $"{eur:F2} €"; - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs deleted file mode 100644 index 0260670..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs +++ /dev/null @@ -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 DbTypes { get; } = [DatabaseType.MySql, DatabaseType.Postgres, DatabaseType.MsSql, DatabaseType.MongoDb]; - public List DbAccessLevels { get; } = [DatabaseAccessLevel.ReadOnly, DatabaseAccessLevel.ReadWrite, DatabaseAccessLevel.Admin]; - public List FileRwAccessLevels { get; } = [FileRWAccessLevel.Denied, FileRWAccessLevel.Read, FileRWAccessLevel.ReadWrite, FileRWAccessLevel.Admin]; - - public Dictionary? Result { get; private set; } - public event Action? CloseRequested; - - public ToolSettingsViewModel() : this("FTP", new()) { } - - public ToolSettingsViewModel(string toolName, Dictionary 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(); - } -} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs b/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs deleted file mode 100644 index 0b15a99..0000000 --- a/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ClawdDotNet.Desktop.Services; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace ClawdDotNet.Desktop.ViewModels; - -/// -/// Grundlage aller Ansichtsmodelle. -/// -/// aus dem CommunityToolkit bringt -/// INotifyPropertyChanged samt Quelltextgenerator mit — aus -/// [ObservableProperty] private string _name; 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 Invoke/BeginInvoke). -/// -/// macht die Sprachpakete in jeder Ansicht als -/// {Binding L[schluessel]} erreichbar. -/// -public abstract class ViewModelBase : ObservableObject -{ - public LocalizationManager L => LocalizationManager.Instance; -} diff --git a/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml deleted file mode 100644 index 4704a23..0000000 --- a/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs deleted file mode 100644 index 2cc01f3..0000000 --- a/src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs +++ /dev/null @@ -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); - } - - /// - /// Setzt die gespeicherten Teilerstellungen. - /// - /// Von Hand und nicht per Bindung: und - /// liegen nicht im Sichtbarkeitsbaum und erben deshalb - /// keinen DataContext. Eine Bindung auf ihre Breite bliebe ohne Wirkung — und zwar - /// ohne Fehlermeldung. - /// - 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])); - } - - /// - /// 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. - /// - 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 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 OnRequestTextEditorDialog(string title, string text) - { - if (TopLevel.GetTopLevel(this) is not Window window) return null; - - return await TextEditorWindow.ShowAsync(window, title, text); - } -} diff --git a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml b/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml deleted file mode 100644 index 4ded51f..0000000 --- a/src/ClawdDotNet.Desktop/Views/BackupPageView.axaml +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs deleted file mode 100644 index 21db0ba..0000000 --- a/src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace ClawdDotNet.Desktop.Views; - -public partial class InfoPageView : UserControl -{ - public InfoPageView() - { - InitializeComponent(); - } -} diff --git a/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml b/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml deleted file mode 100644 index 5768ab3..0000000 --- a/src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs deleted file mode 100644 index bbf7971..0000000 --- a/src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs +++ /dev/null @@ -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(); - }; - } -} diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml deleted file mode 100644 index 8ed1080..0000000 --- a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs deleted file mode 100644 index a1c654c..0000000 --- a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs +++ /dev/null @@ -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; - } - - /// - /// Schließt ohne Rückfrage — für eine Beendigung, die nicht von diesem Fenster ausgeht. - /// - /// Warum das nötig ist. 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. - /// - 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 ShowExitConfirmationAsync(Window? owner) - { - var completion = new TaskCompletionSource(); - 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; - } -} diff --git a/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml b/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml deleted file mode 100644 index 4a22dfb..0000000 --- a/src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs deleted file mode 100644 index 790d578..0000000 --- a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace ClawdDotNet.Desktop.Views; - -public partial class SettingsPageView : UserControl -{ - public SettingsPageView() - { - InitializeComponent(); - } -} diff --git a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml deleted file mode 100644 index 9ae3046..0000000 --- a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs deleted file mode 100644 index fae5b62..0000000 --- a/src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs +++ /dev/null @@ -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 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 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; - } -} diff --git a/src/ClawdDotNet.Desktop/Views/TextEditorWindow.axaml b/src/ClawdDotNet.Desktop/Views/TextEditorWindow.axaml deleted file mode 100644 index c97b157..0000000 --- a/src/ClawdDotNet.Desktop/Views/TextEditorWindow.axaml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/TokenUsagePageView.axaml.cs b/src/ClawdDotNet.Desktop/Views/TokenUsagePageView.axaml.cs deleted file mode 100644 index d3d57b3..0000000 --- a/src/ClawdDotNet.Desktop/Views/TokenUsagePageView.axaml.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Avalonia.Controls; - -namespace ClawdDotNet.Desktop.Views; - -public partial class TokenUsagePageView : UserControl -{ - public TokenUsagePageView() => InitializeComponent(); -} diff --git a/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml b/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml deleted file mode 100644 index f05bd54..0000000 --- a/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml.cs b/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml.cs deleted file mode 100644 index 99ec1b7..0000000 --- a/src/ClawdDotNet.Desktop/Views/ToolSettingsView.axaml.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Avalonia.Controls; - -namespace ClawdDotNet.Desktop.Views; - -public partial class ToolSettingsView : UserControl -{ - public ToolSettingsView() => InitializeComponent(); -} diff --git a/src/ClawdDotNet.Desktop/i18n/de.json b/src/ClawdDotNet.Desktop/i18n/de.json deleted file mode 100644 index ca9f13f..0000000 --- a/src/ClawdDotNet.Desktop/i18n/de.json +++ /dev/null @@ -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" -} diff --git a/src/ClawdDotNet.Desktop/i18n/en.json b/src/ClawdDotNet.Desktop/i18n/en.json deleted file mode 100644 index 6590e4f..0000000 --- a/src/ClawdDotNet.Desktop/i18n/en.json +++ /dev/null @@ -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" -} diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 0bbb055..7cbdfa3 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -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) => { diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 050b919..eeaf256 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -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.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 3404d67..8be69d6 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -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'.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js index 4fbf8ac..b582b16 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js @@ -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", diff --git a/src/ClawdDotNet.Manager/InstanceChannel.cs b/src/ClawdDotNet.Manager/InstanceChannel.cs index ff39826..75ff499 100644 --- a/src/ClawdDotNet.Manager/InstanceChannel.cs +++ b/src/ClawdDotNet.Manager/InstanceChannel.cs @@ -58,14 +58,6 @@ public interface IInstanceChannel /// Socket. /// Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default); - - /// - /// Bittet die Instanz, ihr Fenster nach vorn zu holen (E-M2). - /// - /// false heißt: Die Bitte kam nicht an, oder diese Instanz hat kein - /// Fenster — sie läuft kopflos. Beides ist kein Fehler, sondern eine Auskunft. - /// - Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default); } /// @@ -135,10 +127,6 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC } } - public Task RequestFocusAsync( - string instanceDirectory, CancellationToken ct = default) - => PostAsync(instanceDirectory, "/api/instanz/fenster", ct); - public Task RequestStopAsync( string instanceDirectory, CancellationToken ct = default) => PostAsync(instanceDirectory, "/api/instanz/beenden", ct); diff --git a/src/ClawdDotNet.Manager/InstanceLauncher.cs b/src/ClawdDotNet.Manager/InstanceLauncher.cs index 8790369..49f8599 100644 --- a/src/ClawdDotNet.Manager/InstanceLauncher.cs +++ b/src/ClawdDotNet.Manager/InstanceLauncher.cs @@ -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. /// +/// #764: Seit die Fenster entfernt sind, ist kopflos die einzige Betriebsart — +/// --headless geht immer mit. +/// /// --managed geht immer mit. 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. /// -public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true) +public sealed class ProcessInstanceLauncher(string executablePath) : IInstanceLauncher { /// Wo die Programmdatei liegt — in der Regel neben der des Managers. @@ -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"); diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs index 8cf33cc..a0b4ce3 100644 --- a/src/ClawdDotNet.Manager/InstanceRegistry.cs +++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs @@ -215,28 +215,6 @@ public sealed class InstanceRegistry : IAsyncDisposable public Task StopAsync(string instanceDirectory, CancellationToken ct = default) => Find(instanceDirectory).StopAsync(ct); - /// - /// Holt das Fenster einer Instanz nach vorn — der Wechsel, wie E-M2 ihn meint. - /// - /// Das ist der Ersatz für den Selbst-Neustart. 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. - /// - /// false heisst: Sie läuft nicht, sie hört nicht, oder sie hat kein - /// Fenster. - /// - public async Task 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); - } - /// /// Hält alle laufenden Instanzen an. Nicht beim Beenden des Managers, sondern /// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf. diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs index 2ebef3d..1bc19d3 100644 --- a/src/ClawdDotNet.Manager/ManagerCore.cs +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -298,9 +298,6 @@ public sealed class ManagerCore : IAsyncDisposable public Task StopInstanceAsync(string instanceDirectory, CancellationToken ct = default) => Instances.StopAsync(instanceDirectory, ct); - /// - public Task FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default) - => Instances.FocusAsync(instanceDirectory, ct); // ─── Das Urteil ─── diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs index ecf7759..c7df709 100644 --- a/src/ClawdDotNet.Manager/ManagerSettings.cs +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -47,6 +47,11 @@ public sealed class ManagerSettings /// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier false hin; /// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu /// bedienen. + /// + /// #764: wird nicht mehr ausgewertet. Die Avalonia-Fenster sind entfernt; + /// jede Instanz läuft kopflos. Der Schlüssel bleibt lesbar, damit eine bestehende + /// manager.json ihn nicht als unbekannt mitschleppt oder beim Laden scheitert — + /// gelöscht wird er beim nächsten Speichern nicht, er tut nur nichts mehr. /// [JsonPropertyName("instanceWindows")] public bool InstanceWindows { get; set; } = true; diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs index 9eed6d9..a4e6fdf 100644 --- a/src/ClawdDotNet.ManagerApp/Program.cs +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -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( diff --git a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs index ae01b94..edea8a6 100644 --- a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs @@ -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 HeadlessRun, mit Fenster die /// Avalonia-Anwendung. +/// +/// #764: Seit die Avalonia-Fenster entfernt sind, ist HeadlessRun der einzige +/// Wirt. Mit den Fenstern ging auch „Fenster nach vorn holen" (E-M2): Es gibt keines mehr. /// public sealed class InstanceControl { @@ -48,14 +51,6 @@ public sealed class InstanceControl /// Der Manager bittet, diesen Prozess zu beenden. public event Action? ShutdownRequested; - /// - /// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2). - /// - /// Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins - /// Leere, und das ist die richtige Antwort. - /// - public event Action? FocusRequested; - /// /// 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; } - /// - /// Holt das Fenster nach vorn. Beliebig oft — anders als beim Beenden ist die - /// Wiederholung hier der Normalfall: Jeder Wechsel auf diese Instanz ist einer. - /// - public void RequestFocus() => FocusRequested?.Invoke(); } /// @@ -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. +/// 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. /// 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 }); - }); } } diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs index dd22ddc..c1f8e9b 100644 --- a/src/ClawdDotNet.Web/InstanceHttpHost.cs +++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs @@ -56,8 +56,8 @@ public sealed class InstanceHttpHost : IAsyncDisposable /// /// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden - /// (POST /api/instanz/beenden) oder sein Fenster nach vorn holen - /// (POST /api/instanz/fenster). + /// (POST /api/instanz/beenden). Bis #764 auch: sein Fenster nach vorn holen — + /// das Fenster gibt es nicht mehr. /// /// 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 diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs index 4880cf8..4ca4b68 100644 --- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs +++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs @@ -186,16 +186,4 @@ internal sealed class FakeChannel : IInstanceChannel OnStopRequest?.Invoke(instanceDirectory); return Task.FromResult(StopRequestArrives); } - - /// Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen. - public List FocusRequests { get; } = []; - - /// Ob die Instanz ein Fenster hat, das nach vorn kommen kann. - public bool HasWindow { get; set; } = true; - - public Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default) - { - FocusRequests.Add(Path.GetFullPath(instanceDirectory)); - return Task.FromResult(HasWindow); - } } diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs index 24c3356..ea47bd9 100644 --- a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs +++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs @@ -222,98 +222,3 @@ public sealed class ManagerCoreTests : IAsyncDisposable (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart); } } - -/// -/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5). -/// -/// 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 — und genau dabei hat sich am 2026-09-08 die -/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn. -/// -public sealed class InstanceFocusTests : IAsyncDisposable -{ - private readonly string _root = Path.Combine( - Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]); - - private readonly FakeLauncher _launcher = new(); - private readonly FakeChannel _kanal = new(); - private readonly InstanceDirectoryManager _verzeichnisse; - - private static readonly SupervisionOptions Options = new( - ProbeInterval: TimeSpan.FromMilliseconds(50), - StartTimeout: TimeSpan.FromMilliseconds(600), - SilentChecksBeforeAlarm: 3, - StopGrace: TimeSpan.FromMilliseconds(300), - SilentKillGrace: TimeSpan.FromMilliseconds(300), - MaxDrainWait: TimeSpan.FromSeconds(2)); - - public InstanceFocusTests() - { - Directory.CreateDirectory(_root); - _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); - // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im - // Betrieb (siehe FakeChannel.Launcher). - _kanal.Launcher = _launcher; - } - - private InstanceRegistry Register() => new( - _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); - - public async ValueTask DisposeAsync() - { - await Task.CompletedTask; - try { Directory.Delete(_root, recursive: true); } - catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } - } - - [Fact] - public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt() - { - await using var register = Register(); - var pfad = register.Create("Alpha"); - - _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); - await register.StartAsync(pfad); - - (await register.FocusAsync(pfad)).ShouldBeTrue(); - - _kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]); - - // Und vor allem: Es wurde nichts beendet und nichts neu gestartet. - _launcher.Starts.ShouldBe(1); - _kanal.StopRequests.ShouldBe(0); - _launcher.Last!.HasExited.ShouldBeFalse(); - } - - [Fact] - public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster() - { - await using var register = Register(); - var pfad = register.Create("Alpha"); - - (await register.FocusAsync(pfad)).ShouldBeFalse(); - - // Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht. - _kanal.FocusRequests.ShouldBeEmpty(); - } - - /// - /// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg - /// zu melden. - /// - [Fact] - public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat() - { - await using var register = Register(); - var pfad = register.Create("Alpha"); - - _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); - await register.StartAsync(pfad); - - _kanal.HasWindow = false; - - (await register.FocusAsync(pfad)).ShouldBeFalse(); - _kanal.FocusRequests.ShouldHaveSingleItem(); - } -} diff --git a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs index fd626f0..c6ad92f 100644 --- a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs +++ b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs @@ -247,27 +247,6 @@ public sealed class InstanceControlTests : IDisposable return port; } - [Fact] - public async Task Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an() - { - await using var host = NeuerHost(FreierPort()); - await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); - - web.ShouldNotBeNull(); - - var gebeten = 0; - web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten); - - using var client = InstanceEndpoint.CreateClient(host.InstancePath); - (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); - - gebeten.ShouldBe(1); - - // Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer. - (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); - gebeten.ShouldBe(2); - } - [Fact] public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht() { @@ -280,15 +259,12 @@ public sealed class InstanceControlTests : IDisposable web!.Url.ShouldNotBeNull(); var gebeten = false; - web.Control.FocusRequested += () => gebeten = true; + web.Control.ShutdownRequested += () => gebeten = true; using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); // Mit gueltigem Token - und trotzdem nicht vorhanden. - (await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode - .ShouldBe(System.Net.HttpStatusCode.NotFound); - (await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode .ShouldBe(System.Net.HttpStatusCode.NotFound);