UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
registry.Register(new AgentEditorTool());
|
||||
registry.Register(new Tools.Memory.MemoryTool());
|
||||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||||
registry.Register(new Tools.RocketChat.RocketChatTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
@@ -468,8 +469,18 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||||
public static string BuildSummary => ReleaseInfo.Summary;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Beim Update fahren wir vor dem Beenden selbst herunter; danach ruft die
|
||||
// Oberflaeche Shutdown, und deren Behandlung raeumt ein zweites Mal auf. Ohne
|
||||
// diese Sperre gingen alle Schritte doppelt los — unter anderem ein zweiter
|
||||
// Abmeldevorgang beim Watchdog, der den gerade gesetzten Wartungszustand
|
||||
// wieder ueberschreibt.
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var step in _shutdown)
|
||||
{
|
||||
try { await step(); }
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.RocketChat\ClawdDotNet.Tools.RocketChat.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
|
||||
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
|
||||
|
||||
@@ -210,9 +210,30 @@ public sealed class DeploymentcenterService : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
var client = new global::Deploymentcenter.Client.UpdateClient();
|
||||
|
||||
// Seit 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth; der Lizenzschlüssel
|
||||
// ist der Zugang. Die Anwendung kennt ihn ohnehin, es muss nichts zusätzlich
|
||||
// verteilt werden. Ohne ihn kommt ab dem Scharfschalten nur noch 401.
|
||||
//
|
||||
// platform bleibt offen: Das SDK setzt die Kennung des laufenden Systems ein.
|
||||
// Sie wegzulassen wäre keine Option — der Server liefert dann ausschließlich
|
||||
// plattformunabhängige Pakete.
|
||||
var result = await client.CheckForUpdateAsync(
|
||||
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
|
||||
settings.UpdateChannel, cancellationToken: ct);
|
||||
settings.UpdateChannel,
|
||||
credentials: global::Deploymentcenter.Client.ReleaseCredentials
|
||||
.FromLicenseKey(settings.LicenseKey),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Ein 401 ist kein Netzwerkfehler, sondern eine Aussage über die Lizenz.
|
||||
// Ohne die Unterscheidung sucht man bei einem abgelaufenen Vertrag am Netz.
|
||||
if (result.Unauthorized)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Update-Prüfung abgewiesen: {Message} – der Lizenzschlüssel öffnet die "
|
||||
+ "Release-Ablage nicht (mehr).", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
@@ -247,6 +268,78 @@ public sealed class DeploymentcenterService : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet dem Watchdog, dass das Herunterfahren gleich ein Update ist und kein
|
||||
/// Ausfall. Muss <b>vor</b> dem Herunterfahren gerufen werden — die Meldung geht
|
||||
/// beim Beenden des Heartbeats raus.
|
||||
/// </summary>
|
||||
public void AnnounceUpdate(string version)
|
||||
=> _heartbeat?.AnnounceMaintenance($"Update auf {version} wird eingespielt.");
|
||||
|
||||
/// <summary>
|
||||
/// Startet den <c>update-agent</c> und übergibt ihm das Feld.
|
||||
///
|
||||
/// <para><b>Beendet die Anwendung bewusst nicht selbst.</b> <c>LaunchUpdateAgent</c>
|
||||
/// kann das (<c>exitCurrentApp</c>), täte es aber über
|
||||
/// <see cref="Environment.Exit"/> — und das übergeht jedes Aufräumen: Der Watchdog
|
||||
/// meldete nichts, Instanzdatenbank und Chatverläufe blieben auf halbem Weg stehen.
|
||||
/// Der Aufrufer fährt deshalb erst geordnet herunter und beendet danach selbst.
|
||||
/// Der Agent bekommt unsere Prozesskennung und wartet, bis wir wirklich weg
|
||||
/// sind.</para>
|
||||
/// </summary>
|
||||
/// <returns>Der Grund, wenn es nicht losging; sonst <c>null</c>.</returns>
|
||||
public string? StartUpdate(AppSettings settings, string currentVersion)
|
||||
{
|
||||
var agentPath = global::Deploymentcenter.Client.UpdateClient.ResolveAgentPath();
|
||||
|
||||
if (agentPath is null)
|
||||
{
|
||||
// Kein fest verdrahteter Name: Unter Linux trägt der Agent keine Endung.
|
||||
_logger.LogWarning(
|
||||
"Update angefordert, aber kein update-agent neben der Anwendung gefunden.");
|
||||
|
||||
return "Der update-agent wurde nicht gefunden. Er gehört neben die "
|
||||
+ "Programmdatei; das Installationspaket bringt ihn normalerweise mit.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = global::Deploymentcenter.Client.UpdateClient.LaunchUpdateAgent(
|
||||
agentPath: agentPath,
|
||||
projectId: LicenseInfo.ProductSlug,
|
||||
channel: settings.UpdateChannel,
|
||||
action: "update",
|
||||
version: "latest",
|
||||
exitCurrentApp: false,
|
||||
currentVersion: currentVersion,
|
||||
waitForCurrentProcess: true,
|
||||
licenseKey: settings.LicenseKey,
|
||||
|
||||
// Ohne Signatur nicht installieren. Der SHA256 stammt aus derselben
|
||||
// Quelle wie das Paket — wer den Webroot oder die FTP-Zugangsdaten
|
||||
// kontrolliert, tauscht beide gemeinsam aus. Ausgerechnet auf dem Pfad,
|
||||
// der anschließend fremden Code ausführt.
|
||||
//
|
||||
// Tragfähig, weil ab 0.1.2 jedes Release signiert ist. Ein Rücksprung
|
||||
// auf 0.1.0 oder 0.1.1 bricht damit ab — das ist gewollt: Die beiden
|
||||
// stammen aus der Zeit vor dem Signierschlüssel.
|
||||
requireSignature: true);
|
||||
|
||||
if (!started)
|
||||
return "Der update-agent ließ sich nicht starten.";
|
||||
|
||||
_logger.LogInformation("update-agent gestartet ({Path}), Anwendung wird beendet.",
|
||||
agentPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "update-agent konnte nicht gestartet werden.");
|
||||
return $"Der update-agent konnte nicht gestartet werden: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_heartbeat is not null)
|
||||
|
||||
@@ -36,7 +36,12 @@ public sealed class OpenRouterStatusService : IAsyncDisposable
|
||||
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
|
||||
|
||||
private const double UsdToEur = 0.92;
|
||||
/// <summary>
|
||||
/// Grober Umrechnungskurs fuer die Anzeige. Oeffentlich, damit die Tokennutzungs-
|
||||
/// Seite denselben Wert verwendet — zwei Kopien eines veraltenden Kurses waeren
|
||||
/// zwei Gelegenheiten, verschiedene Betraege fuer dieselben Laeufe zu zeigen.
|
||||
/// </summary>
|
||||
public const double UsdToEur = 0.92;
|
||||
|
||||
public bool IsApiReachable { get; private set; }
|
||||
public string StatusText { get; private set; } = "Prüfe...";
|
||||
|
||||
@@ -52,6 +52,46 @@ public sealed class AppSettings
|
||||
[JsonPropertyName("logRefreshIntervalMs")]
|
||||
public int LogRefreshIntervalMs { get; set; } = 500;
|
||||
|
||||
// ─── Zustand der Oberflaeche ───
|
||||
//
|
||||
// Bewusst hier und nicht in der Instanzkonfiguration: Das sind Gewohnheiten des
|
||||
// Benutzers am Geraet, keine Eigenschaften der Instanz. Wer zwischen zwei Instanzen
|
||||
// wechselt, will nicht jedes Mal wieder im Hellmodus mit ausgeklappter Leiste landen.
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Farbschema")]
|
||||
[Description("Light oder Dark. Umschaltbar ueber das Symbol in der Kopfzeile.")]
|
||||
[JsonPropertyName("theme")]
|
||||
public string Theme { get; set; } = "Light";
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Seitenleiste eingeklappt")]
|
||||
[Description("Merkt sich, ob die Navigation auf Symbolbreite zusammengeklappt war.")]
|
||||
[JsonPropertyName("sidebarCollapsed")]
|
||||
public bool SidebarCollapsed { get; set; }
|
||||
|
||||
// Die drei Teiler der Agenten-Seite, jeweils als Anteil 0..1 der Gesamtflaeche.
|
||||
// Als Verhaeltnis und nicht in Pixeln: Sonst sitzt der Teiler nach einem Wechsel
|
||||
// auf einen anderen Bildschirm an einer voellig anderen Stelle.
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Agenten-Seite: Teiler waagerecht")]
|
||||
[Description("Anteil des oberen Bereichs (Agentenliste und Einstellungen), 0.2 bis 0.8.")]
|
||||
[JsonPropertyName("agentsSplitVertical")]
|
||||
public double AgentsSplitVertical { get; set; } = 0.55;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Agenten-Seite: Teiler oben")]
|
||||
[Description("Anteil der Agentenliste an der Breite des oberen Bereichs, 0.2 bis 0.8.")]
|
||||
[JsonPropertyName("agentsSplitTopList")]
|
||||
public double AgentsSplitTopList { get; set; } = 0.44;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Agenten-Seite: Teiler unten")]
|
||||
[Description("Anteil der Werkzeugliste an der Breite des unteren Bereichs, 0.2 bis 0.8.")]
|
||||
[JsonPropertyName("agentsSplitBottomList")]
|
||||
public double AgentsSplitBottomList { get; set; } = 0.44;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("Status-Check-Intervall (Sek)")]
|
||||
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||||
private Task? _loop;
|
||||
private string _lastState = "unknown";
|
||||
|
||||
private string _shutdownStatus = WatchdogStatus.Stopped;
|
||||
private string _shutdownMessage = "Instanz planmäßig beendet.";
|
||||
private string _shutdownEventKind = WatchdogEventKind.StoppedGraceful;
|
||||
|
||||
public WatchdogHeartbeatService(
|
||||
IWatchdogClient client,
|
||||
IInstanceHealthProvider health,
|
||||
@@ -57,6 +61,23 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||||
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
|
||||
public string LastState => _lastState;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet das nächste Herunterfahren als <b>Wartung</b> statt als planmäßiges Ende.
|
||||
///
|
||||
/// <para>Gedacht für das Einspielen eines Updates: Die Instanz ist gleich weg,
|
||||
/// kommt aber wieder. <c>stopped</c> wäre die falsche Auskunft — es heißt „bewusst
|
||||
/// beendet" und lässt den Monitor liegen, bis jemand ihn wieder anfasst.
|
||||
/// <c>maintenance</c> sagt dasselbe über den Alarm aus, trägt aber die Absicht
|
||||
/// mit: Im Dashboard ist zu sehen, dass hier gerade aktualisiert wird, statt dass
|
||||
/// eine Instanz ohne Grund verschwindet.</para>
|
||||
/// </summary>
|
||||
public void AnnounceMaintenance(string message)
|
||||
{
|
||||
_shutdownStatus = WatchdogStatus.Maintenance;
|
||||
_shutdownMessage = message;
|
||||
_shutdownEventKind = WatchdogEventKind.MaintenanceStart;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsRunning)
|
||||
@@ -176,12 +197,12 @@ public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||||
|
||||
await _client.SendHeartbeatAsync(
|
||||
new InstanceHealth(
|
||||
WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
|
||||
_shutdownStatus, _shutdownMessage,
|
||||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
|
||||
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
|
||||
|
||||
await _client.SendEventAsync(
|
||||
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
|
||||
_shutdownEventKind, "info", _shutdownMessage, null, stopCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -47,6 +47,12 @@ public sealed class StagingPolicy
|
||||
["Database.upsert"] = StagingDecision.Approve,
|
||||
["FileRW.delete"] = StagingDecision.Approve,
|
||||
["FTP.upload"] = StagingDecision.Approve,
|
||||
["FTP.delete"] = StagingDecision.Approve
|
||||
["FTP.delete"] = StagingDecision.Approve,
|
||||
|
||||
// Rocket.Chat ist der Arbeitsraum, nicht ein Kanal nach draußen: Müsste jede
|
||||
// Antwort freigegeben werden, wäre er unbenutzbar. Der Schutz sitzt dort an
|
||||
// der Raum-Allowlist des Tools, deshalb bleibt send_message bewusst `auto`.
|
||||
// Eine Datei ist etwas anderes — sie verlässt den Workspace als Ganzes.
|
||||
["RocketChat.send_file"] = StagingDecision.Approve
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:ClawdDotNet.Desktop"
|
||||
x:Class="ClawdDotNet.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
RequestedThemeVariant="Light">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator />
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -5,6 +6,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Desktop.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
@@ -13,8 +15,18 @@ namespace ClawdDotNet.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Schalter der Befehlszeile, mit dem sich ClawdDotNet auf eine bestimmte Instanz
|
||||
/// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der
|
||||
/// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert.
|
||||
/// </summary>
|
||||
private const string InstanceArgument = "--instance";
|
||||
|
||||
private AppHost? _host;
|
||||
|
||||
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
|
||||
private string? _preselectedInstancePath;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
@@ -26,6 +38,10 @@ public partial class App : Application
|
||||
// in genau dieser Lücke aussteigen.
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
_preselectedInstancePath = ReadInstanceArgument(desktop.Args);
|
||||
|
||||
ApplySavedThemeEarly();
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
@@ -40,6 +56,30 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint.
|
||||
///
|
||||
/// <para>Das Ansichtsmodell des Hauptfensters tut dasselbe — aber erst, wenn der
|
||||
/// Aufbau steht. Dazwischen liegt die Instanzauswahl, und die waere sonst bei einem
|
||||
/// Benutzer im Dunkelmodus hell und der Rest der Anwendung dunkel.</para>
|
||||
///
|
||||
/// <para>Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und
|
||||
/// schreibt allenfalls beim allerersten Start die Vorgaben fest.</para>
|
||||
/// </summary>
|
||||
private static void ApplySavedThemeEarly()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = new SettingsManager();
|
||||
settings.Load();
|
||||
ThemeManager.Apply(ThemeManager.IsDarkName(settings.AppSettings.Theme));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ohne lesbare Einstellungen bleibt es beim hellen Schema.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var result = await AppHost.StartAsync(new AppHost.Callbacks
|
||||
@@ -70,15 +110,18 @@ public partial class App : Application
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(_host)
|
||||
};
|
||||
var mainViewModel = new MainWindowViewModel(_host);
|
||||
|
||||
// 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 };
|
||||
|
||||
desktop.MainWindow.Show();
|
||||
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
||||
|
||||
await ShowUpdateNoticeAsync(_host);
|
||||
await OfferUpdateAsync(_host, desktop);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,11 +175,13 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: 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.
|
||||
/// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll.
|
||||
///
|
||||
/// <para>Bewusst eine Frage und kein Selbstlauf: Wann aktualisiert wird, entscheidet
|
||||
/// der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
|
||||
/// erneuern, ist genau dann im Weg, wenn man sie braucht.</para>
|
||||
/// </summary>
|
||||
private static async Task ShowUpdateNoticeAsync(AppHost host)
|
||||
private static async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (host.Deploymentcenter is null) return;
|
||||
|
||||
@@ -146,13 +191,202 @@ public partial class App : Application
|
||||
|
||||
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
|
||||
|
||||
await new AvaloniaLicensePrompt().ShowInfoAsync(
|
||||
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."
|
||||
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
|
||||
$"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);
|
||||
}
|
||||
|
||||
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
|
||||
/// <summary>
|
||||
/// Die Reihenfolge ist der ganze Punkt dieser Methode.
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item>Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das
|
||||
/// Verschwinden der Instanz aus wie ein Ausfall.</item>
|
||||
/// <item>Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung.</item>
|
||||
/// <item>Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet,
|
||||
/// bis wir weg sind, statt über gesperrte Dateien zu kopieren.</item>
|
||||
/// <item>Selbst beenden.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Die Verlockung wäre, <c>LaunchUpdateAgent</c> das Beenden zu überlassen —
|
||||
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
|
||||
/// vollständig.</para>
|
||||
/// </summary>
|
||||
private static async Task StartUpdateAsync(
|
||||
AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version)
|
||||
{
|
||||
host.Deploymentcenter!.AnnounceUpdate(version);
|
||||
|
||||
var settings = host.Settings.AppSettings;
|
||||
await host.DisposeAsync();
|
||||
|
||||
var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion);
|
||||
|
||||
if (problem is not null)
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Update", problem);
|
||||
|
||||
// Der Aufbau ist bereits abgebaut — weiterarbeiten wäre nicht ehrlich.
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(1));
|
||||
return;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
|
||||
}
|
||||
|
||||
/// <summary>Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit.</summary>
|
||||
private static async Task<bool> ConfirmAsync(
|
||||
string title, string message, string confirmText, string cancelText)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource<bool>();
|
||||
|
||||
var confirm = new Button { Content = confirmText, IsDefault = true };
|
||||
var cancel = new Button { Content = cancelText, IsCancel = true };
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 480,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var answer = false;
|
||||
confirm.Click += (_, _) => { answer = true; window.Close(); };
|
||||
cancel.Click += (_, _) => window.Close();
|
||||
|
||||
// Schließt der Benutzer das Fenster, gilt das als „Später".
|
||||
window.Closed += (_, _) => completion.TrySetResult(answer);
|
||||
|
||||
window.Show();
|
||||
return await completion.Task;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Instanzwechsel aus der laufenden Anwendung.
|
||||
///
|
||||
/// <para>Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine
|
||||
/// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an
|
||||
/// <em>einer</em> Instanz. Statt das umzubauen startet sich die Anwendung mit
|
||||
/// <c>--instance</c> neu — derselbe Weg, den auch das Update geht.</para>
|
||||
///
|
||||
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
|
||||
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
|
||||
/// </summary>
|
||||
private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (_host is null) 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;
|
||||
}
|
||||
|
||||
await _host.DisposeAsync();
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(executable)
|
||||
{
|
||||
ArgumentList = { InstanceArgument, path },
|
||||
UseShellExecute = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync(
|
||||
"ClawdDotNet – Instanzwechsel",
|
||||
$"Neustart fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
|
||||
}
|
||||
|
||||
private static string? ReadInstanceArgument(IReadOnlyList<string>? args)
|
||||
{
|
||||
if (args is null) return null;
|
||||
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instanzauswahl beim Start.
|
||||
///
|
||||
/// <para>Kam die Anwendung mit <c>--instance</c> hoch, wird nicht gefragt: Dann hat
|
||||
/// der Benutzer die Instanz im vorigen Prozess bereits gewaehlt, und ein zweiter
|
||||
/// Dialog waere nur die Frage nach der schon gegebenen Antwort. Der Wert wird einmal
|
||||
/// verbraucht — bleibt der Pfad ungueltig, erscheint doch der Dialog, statt dass der
|
||||
/// Start wortlos scheitert.</para>
|
||||
/// </summary>
|
||||
private async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
|
||||
{
|
||||
var preselected = _preselectedInstancePath;
|
||||
_preselectedInstancePath = null;
|
||||
|
||||
if (preselected is not null && Directory.Exists(preselected))
|
||||
return preselected;
|
||||
|
||||
return await ShowInstancePickerAsync(directories);
|
||||
}
|
||||
|
||||
private static async Task<string?> ShowInstancePickerAsync(InstanceDirectoryManager directories)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var viewModel = new InstancePickerViewModel(directories);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
@@ -22,17 +22,47 @@
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Assets enthaelt die App-Symbole und Assets\Fonts die beiden mitgelieferten
|
||||
Schriften (Barlow, Barlow Condensed; SIL Open Font License, siehe OFL.txt).
|
||||
Sie werden ueber avares:// referenziert, siehe Styles/Theme.axaml. -->
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Muss neben der Programmdatei liegen, damit sie im Paket neben der manifest.json
|
||||
landet: Von dort liest der update-agent sie bei 'install' und 'configure'. -->
|
||||
<ItemGroup>
|
||||
<Content Include="setup.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Native Debug-Symbole aus NuGet-Paketen nicht mitkopieren. libSkiaSharp.pdb ist
|
||||
allein 80 MB, libHarfBuzzSharp.pdb weitere 20 - sie blaehten die Publish-Ausgabe
|
||||
auf das Dreifache auf, obwohl sie im Paket ohnehin ausgeschlossen sind. Unsere
|
||||
eigenen Symbole sind eingebettet (siehe Directory.Build.props) und daher nicht
|
||||
betroffen. -->
|
||||
<Target Name="RemoveNativeSymbols" AfterTargets="ResolveReferences">
|
||||
<ItemGroup>
|
||||
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
|
||||
Condition="'%(Extension)' == '.pdb'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Dieselbe Bereinigung noch einmal fuer die Publish-Ausgabe: Die nativen Symbole
|
||||
kommen als Laufzeit-Bestandteile ueber ResolvedFileToPublish und werden von der
|
||||
Kopierliste oben gar nicht erfasst. Unbedenklich fuer unsere eigenen Assemblies -
|
||||
deren Symbole stecken eingebettet in der DLL. -->
|
||||
<Target Name="RemoveNativeSymbolsFromPublish" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
|
||||
Condition="'%(Extension)' == '.pdb'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Der Rahmen mit den vier Eckmarken — das wiederkehrende Motiv des Entwurfs.
|
||||
///
|
||||
/// <para>Eigenes Steuerelement statt vier von Hand gesetzter Striche je Stelle: Die Marken
|
||||
/// liegen bewusst <em>halb außerhalb</em> des Rahmens, und das ist genau die Sorte Detail,
|
||||
/// die beim Nachbauen an der fünften Stelle nicht mehr stimmt.</para>
|
||||
///
|
||||
/// <para>Weil die Marken über den Rand hinausragen, darf kein Elternteil zuschneiden.
|
||||
/// In der Praxis heißt das: nicht direkt an den Fensterrand setzen, sondern mit etwas
|
||||
/// Abstand — die Vorlage im Entwurf tut dasselbe.</para>
|
||||
///
|
||||
/// <para>Sparsam verwenden. Im Entwurf tragen nur die Info-Karte und die beiden Dialoge
|
||||
/// die Marken; alle übrigen Karten sind schlichte Haarlinien-Kästen (Klasse
|
||||
/// <c>card</c> in <c>Styles/Shell.axaml</c>).</para>
|
||||
/// </summary>
|
||||
public sealed class BlueprintFrame : ContentControl
|
||||
{
|
||||
protected override Type StyleKeyOverride => typeof(BlueprintFrame);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Strichsymbol aus dem Entwurf — 24×24-Geometrie, gezeichnet mit der geerbten
|
||||
/// Vordergrundfarbe.
|
||||
///
|
||||
/// <para>Warum nicht <c>PathIcon</c>: Das füllt seine Geometrie. Die Symbole des Entwurfs
|
||||
/// sind Umrisse (Lucide, Strichstärke 1.5); gefüllt werden daraus schwarze Klumpen.</para>
|
||||
///
|
||||
/// <para>Warum nicht <c>Path</c> mit <c>Stroke</c>: Dann müsste an jeder einzelnen Stelle
|
||||
/// die Farbe mitgegeben werden — und zwar die <em>richtige</em>, je nachdem ob das Symbol
|
||||
/// in einer Schaltfläche, einer ausgewählten Navigationszeile oder der Statusleiste sitzt.
|
||||
/// <see cref="Foreground"/> ist hier von <see cref="TextElement"/> übernommen und erbt
|
||||
/// daher wie eine Textfarbe: Wer den Text einfärbt, färbt das Symbol mit.</para>
|
||||
///
|
||||
/// <para>Die Strichstärke wird beim Skalieren mitskaliert. Das ist keine Nachlässigkeit,
|
||||
/// sondern das Verhalten von SVG: <c>stroke-width</c> zählt in Einheiten des viewBox.
|
||||
/// Ein Symbol mit 1.5 in 24×24, dargestellt auf 16 px, hat genau 1 px starke Linien.</para>
|
||||
/// </summary>
|
||||
public sealed class StrokeIcon : Control
|
||||
{
|
||||
/// <summary>Kantenlänge des Koordinatenraums aller Symbole (SVG-viewBox 0 0 24 24).</summary>
|
||||
private const double ViewBox = 24d;
|
||||
|
||||
public static readonly StyledProperty<Geometry?> DataProperty =
|
||||
AvaloniaProperty.Register<StrokeIcon, Geometry?>(nameof(Data));
|
||||
|
||||
/// <summary>Geerbt wie eine Textfarbe — siehe Klassenbeschreibung.</summary>
|
||||
public static readonly StyledProperty<IBrush?> ForegroundProperty =
|
||||
TextElement.ForegroundProperty.AddOwner<StrokeIcon>();
|
||||
|
||||
public static readonly StyledProperty<double> StrokeThicknessProperty =
|
||||
AvaloniaProperty.Register<StrokeIcon, double>(nameof(StrokeThickness), 1.5d);
|
||||
|
||||
/// <summary>Für die wenigen massiven Symbole des Entwurfs (Wiedergabe, Halt).</summary>
|
||||
public static readonly StyledProperty<bool> IsFilledProperty =
|
||||
AvaloniaProperty.Register<StrokeIcon, bool>(nameof(IsFilled));
|
||||
|
||||
static StrokeIcon()
|
||||
{
|
||||
AffectsRender<StrokeIcon>(
|
||||
DataProperty, ForegroundProperty, StrokeThicknessProperty, IsFilledProperty);
|
||||
AffectsMeasure<StrokeIcon>(DataProperty);
|
||||
}
|
||||
|
||||
public Geometry? Data
|
||||
{
|
||||
get => GetValue(DataProperty);
|
||||
set => SetValue(DataProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? Foreground
|
||||
{
|
||||
get => GetValue(ForegroundProperty);
|
||||
set => SetValue(ForegroundProperty, value);
|
||||
}
|
||||
|
||||
public double StrokeThickness
|
||||
{
|
||||
get => GetValue(StrokeThicknessProperty);
|
||||
set => SetValue(StrokeThicknessProperty, value);
|
||||
}
|
||||
|
||||
public bool IsFilled
|
||||
{
|
||||
get => GetValue(IsFilledProperty);
|
||||
set => SetValue(IsFilledProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ohne gesetzte Größe 16×16 — die Größe, in der die meisten Symbole im Entwurf stehen.
|
||||
/// Ein unbegrenzter Vorschlag würde das Symbol den ganzen Platz nehmen lassen.
|
||||
/// </summary>
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
var width = double.IsInfinity(availableSize.Width) ? 16d : Math.Min(16d, availableSize.Width);
|
||||
var height = double.IsInfinity(availableSize.Height) ? 16d : Math.Min(16d, availableSize.Height);
|
||||
return new Size(width, height);
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
if (Data is not { } geometry || Foreground is not { } brush) return;
|
||||
|
||||
var scale = Math.Min(Bounds.Width / ViewBox, Bounds.Height / ViewBox);
|
||||
if (scale <= 0) return;
|
||||
|
||||
// Mittig setzen, falls das Steuerelement nicht quadratisch ist.
|
||||
var offsetX = (Bounds.Width - ViewBox * scale) / 2;
|
||||
var offsetY = (Bounds.Height - ViewBox * scale) / 2;
|
||||
|
||||
using var _ = context.PushTransform(
|
||||
Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(offsetX, offsetY));
|
||||
|
||||
if (IsFilled)
|
||||
{
|
||||
context.DrawGeometry(brush, null, geometry);
|
||||
return;
|
||||
}
|
||||
|
||||
// Runde Enden und Verbindungen: Ohne sie verschwinden die Punkt-Symbole
|
||||
// (Pfadstücke der Länge 0.01, etwa in „Agenten-Chats") vollständig.
|
||||
var pen = new Pen(brush, StrokeThickness,
|
||||
lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
|
||||
|
||||
context.DrawGeometry(null, pen, geometry);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
|
||||
/// </summary>
|
||||
public sealed class ChatBubbleBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatBubbleBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return UserBubble;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
|
||||
/// </summary>
|
||||
public sealed class ChatAlignmentConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatAlignmentConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return HorizontalAlignment.Right;
|
||||
|
||||
return HorizontalAlignment.Left;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
|
||||
/// </summary>
|
||||
public sealed class ToolMatchConverter : IValueConverter
|
||||
{
|
||||
public static readonly ToolMatchConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string toolName && parameter is string targetTool)
|
||||
return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Schlägt eine Symbol-Geometrie aus <c>Styles/Icons.axaml</c> über ihren Namen nach.
|
||||
///
|
||||
/// <para>Damit halten die Ansichtsmodelle nur den Namen („IconChat") statt einer
|
||||
/// <see cref="Geometry"/> — sie brauchen dann keinen Verweis auf <c>Avalonia.Media</c>,
|
||||
/// und die Navigationsleiste kommt mit einer schlichten Zeichenkette je Eintrag aus.</para>
|
||||
///
|
||||
/// <para>Dass hier keine Themenumschaltung nötig ist, ist kein Zufall: Die Geometrien sind
|
||||
/// themenunabhängig, gefärbt wird erst beim Zeichnen über die geerbte Vordergrundfarbe
|
||||
/// (siehe <c>Controls/StrokeIcon.cs</c>). Für Farben wäre dieser Weg falsch — ein einmal
|
||||
/// aufgelöster Pinsel bliebe beim Wechsel auf Dunkel stehen.</para>
|
||||
/// </summary>
|
||||
public sealed class IconConverter : IValueConverter
|
||||
{
|
||||
public static readonly IconConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string key || string.IsNullOrEmpty(key)) return null;
|
||||
if (Application.Current is not { } app) return null;
|
||||
|
||||
return app.TryFindResource(key, out var resource) ? resource as Geometry : null;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Pfeil am Einklapp-Schalter der Seitenleiste: zeigt nach links, solange
|
||||
/// eingeklappt werden kann, und nach rechts, wenn wieder aufgeklappt wird.
|
||||
/// </summary>
|
||||
public sealed class SidebarChevronConverter : IValueConverter
|
||||
{
|
||||
public static readonly SidebarChevronConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var isCollapsed = value is true;
|
||||
return IconConverter.Instance.Convert(
|
||||
isCollapsed ? "IconChevronRight" : "IconChevronLeft", targetType, parameter, culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Layout;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Richtet Sprechblasen aus: rechts fuer den Benutzer, links fuer den Agenten.
|
||||
///
|
||||
/// <para>Ausrichtung und nicht Farbe — deshalb ist ein Konverter hier richtig. Der
|
||||
/// frueher danebenstehende ChatBubbleBrushConverter lieferte einen Pinsel mit festem
|
||||
/// Farbwert und ueberlebte den Themenwechsel nicht; die Hinterlegung eigener Nachrichten
|
||||
/// laeuft jetzt ueber die Stilklasse <c>card accent</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class ChatAlignmentConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatAlignmentConverter Instance = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is true ? HorizontalAlignment.Right : HorizontalAlignment.Left;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft, ob ein Werkzeugname mit dem <c>ConverterParameter</c> uebereinstimmt.
|
||||
/// Blendet im Werkzeug-Einstellungsbereich die passende Feldgruppe ein.
|
||||
/// </summary>
|
||||
public sealed class ToolMatchConverter : IValueConverter
|
||||
{
|
||||
public static readonly ToolMatchConverter Instance = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is string toolName && parameter is string targetTool
|
||||
&& toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt eine Logzeile nach ihrer Stufe.
|
||||
///
|
||||
/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
|
||||
/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
|
||||
/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
|
||||
/// </summary>
|
||||
public sealed class LogLevelBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly LogLevelBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
|
||||
private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value as string switch
|
||||
{
|
||||
"ERR" or "FTL" => Error,
|
||||
"WRN" => Warn,
|
||||
// null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
|
||||
// Farbe aus dem Thema.
|
||||
_ => null
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht eine Zeichenkette mit dem <c>ConverterParameter</c>, ohne Gross- und
|
||||
/// Kleinschreibung zu unterscheiden.
|
||||
///
|
||||
/// <para>Gedacht fuer <c>Classes.xyz="{Binding …}"</c>: Die Ansicht setzt damit eine
|
||||
/// Stilklasse, und die Farbe dazu steht in <c>Styles/Shell.axaml</c> an
|
||||
/// <c>DynamicResource</c>. Das ersetzt den frueheren LogLevelBrushConverter, der
|
||||
/// Pinsel mit festen Farbwerten zurueckgab — die blieben beim Wechsel auf Dunkel
|
||||
/// unveraendert stehen, weil ein einmal gelieferter Pinsel nichts mehr vom Thema
|
||||
/// mitbekommt.</para>
|
||||
/// </summary>
|
||||
public sealed class StringEqualsConverter : IValueConverter
|
||||
{
|
||||
public static readonly StringEqualsConverter Instance = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is string text && parameter is string expected
|
||||
&& text.Equals(expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -25,9 +25,10 @@ internal static class Program
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
// Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
|
||||
// Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
|
||||
// Oberfläche leer.
|
||||
.WithInterFont()
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hell/Dunkel umschalten — an genau einer Stelle.
|
||||
///
|
||||
/// <para>Die Farbwerte selbst stehen in <c>Styles/Theme.axaml</c> als
|
||||
/// <c>ThemeDictionaries</c>. Hier wird nur umgelegt, welches der beiden Wörterbücher gilt;
|
||||
/// jede Bindung mit <c>DynamicResource</c> zieht dann von allein nach. Deshalb gibt es
|
||||
/// hier auch keine Liste von Steuerelementen, die nachgefärbt werden müssten — die wäre
|
||||
/// beim nächsten neuen Bereich schon wieder unvollständig.</para>
|
||||
/// </summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
/// <summary>Name in den Einstellungen, wenn dunkel gewählt ist.</summary>
|
||||
public const string DarkName = "Dark";
|
||||
|
||||
/// <summary>Übersetzt den gespeicherten Namen; alles außer „Dark" gilt als hell.</summary>
|
||||
public static bool IsDarkName(string? name)
|
||||
=> string.Equals(name, DarkName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static string ToName(bool isDark) => isDark ? DarkName : "Light";
|
||||
|
||||
/// <summary>
|
||||
/// Legt die Variante an der Anwendung um. Ohne laufende Anwendung — etwa im
|
||||
/// Entwurfsmodus des Editors — passiert nichts.
|
||||
/// </summary>
|
||||
public static void Apply(bool isDark)
|
||||
{
|
||||
if (Application.Current is not { } app) return;
|
||||
app.RequestedThemeVariant = isDark ? ThemeVariant.Dark : ThemeVariant.Light;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Die Symbole des Entwurfs, 1:1 aus dem Mockup übernommen (Lucide-Umrisse, viewBox
|
||||
0 0 24 24, Strichstärke 1.5). Gezeichnet werden sie von Controls/StrokeIcon.cs, das
|
||||
denselben Koordinatenraum voraussetzt — wer hier ein Symbol ergänzt, muss es also
|
||||
ebenfalls auf 24×24 bringen.
|
||||
|
||||
Kreise stehen als zwei Halbbögen da (a-Befehle): StreamGeometry kennt keine
|
||||
<Ellipse>, und ein Rechteck mit vollem Radius wäre nicht dasselbe.
|
||||
-->
|
||||
|
||||
<!-- ─── Marke ─── -->
|
||||
<StreamGeometry x:Key="IconLogo">M12 9c-1.5-2.5-4-3-5.5-1.5S5 11 7 12c-1 1-1.5 2-1.5 3.5S7 18 8.5 17c1-1 1.5-2 1.5-3 M12 9c1.5-2.5 4-3 5.5-1.5S19 11 17 12c1 1 1.5 2 1.5 3.5S15 18 13.5 17c-1-1-1.5-2-1.5-3 M12 9v3</StreamGeometry>
|
||||
|
||||
<!-- ─── Navigation ─── -->
|
||||
<StreamGeometry x:Key="IconChat">M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconAgents">M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconTasks">M3 17l2 2 4-4 M3 7l2 2 4-4 M13 6h8 M13 12h8 M13 18h8</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconChart">M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconAgentChats">M8 12h.01 M12 12h.01 M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconBackup">M3 4h18a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9 M10 13h4</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconLogs">M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M9 13h6 M9 17h6</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconSettings">M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9c.13-.32.19-.66.18-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.5 1.24.66 1.82.33H9c.32-.13.66-.19 1-.18A1.65 1.65 0 0 0 11.51 3H12a2 2 0 0 1 4 0v.09c0 .34.06.68.18 1a1.65 1.65 0 0 0 1.82.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06c-.51.5-.67 1.24-.33 1.82V9c.13.32.19.66.18 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconInfo">M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0 M12 16v-4 M12 8h.01</StreamGeometry>
|
||||
|
||||
<!-- ─── Kopfzeile ─── -->
|
||||
<StreamGeometry x:Key="IconSun">M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M12 2v2 M12 20v2 M4.9 4.9l1.4 1.4 M17.7 17.7l1.4 1.4 M2 12h2 M20 12h2 M4.9 19.1l1.4-1.4 M17.7 6.3l1.4-1.4</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconMoon">M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconChevronLeft">M15 18l-6-6 6-6</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconChevronRight">M9 18l6-6-6-6</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconChevronDown">M6 9l6 6 6-6</StreamGeometry>
|
||||
|
||||
<!-- ─── Meldungen ─── -->
|
||||
<StreamGeometry x:Key="IconAlert">M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClose">M18 6L6 18 M6 6l12 12</StreamGeometry>
|
||||
|
||||
<!-- ─── Werkzeugleisten ─── -->
|
||||
<StreamGeometry x:Key="IconPlus">M12 5v14 M5 12h14</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconMinus">M5 12h14</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconPlay">M8 5v14l11-7z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconStop">M6 6h12v12H6z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconPencil">M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconTrash">M3 6h18 M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconFolder">M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconRefresh">M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M20.49 15a9 9 0 0 1-14.85 3.36L1 14</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconPower">M18.36 6.64a9 9 0 1 1-12.73 0 M12 2v10</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconSave">M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8</StreamGeometry>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -1,44 +1,862 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls">
|
||||
|
||||
<!--
|
||||
Gemeinsame Anmutung der Oberflaeche.
|
||||
Die gemeinsame Anmutung — Umsetzung des Entwurfs aus Mockup/.
|
||||
|
||||
Bewusst wenige, benannte Regeln statt Formatierung an jedem Steuerelement: Die
|
||||
WinForms-Fassung hatte Groessen, Abstaende und Schriften ueber 130 Stellen in den
|
||||
Designer-Dateien verteilt: Jede Aenderung war eine Suche.
|
||||
Warum eigene ControlTheme statt Fluent umzufärben: Avalonia 12 hat die Ressourcen des
|
||||
Fluent-Themas umgebaut. Die aus 11er-Anleitungen bekannten Schlüssel (ButtonBackground,
|
||||
TextControlBackground, ControlCornerRadius …) existieren nicht mehr; ein Setter darauf
|
||||
ist wirkungslos und fällt nicht auf, weil er weder Build noch Test bricht. Für die
|
||||
prägenden Steuerelemente steht die Vorlage deshalb hier vollständig. Fluent bleibt
|
||||
darunter als Grundlage für alles Übrige (Bildlaufleisten, Menüs, Popup-Rahmen).
|
||||
|
||||
Regel wie gehabt: keine festen Farbwerte. Alles über DynamicResource aus Theme.axaml,
|
||||
sonst überlebt es den Wechsel zwischen hell und dunkel nicht.
|
||||
-->
|
||||
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Styles.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://ClawdDotNet/Styles/Theme.axaml" />
|
||||
<ResourceInclude Source="avares://ClawdDotNet/Styles/Icons.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- ════════════════════════════ Schaltflächen ════════════════════════════
|
||||
|
||||
Grundform ist die sekundäre Schaltfläche des Entwurfs: durchsichtig, eine
|
||||
Haarlinie Rahmen, eckig. Die Spielarten kommen als Klasse dazu:
|
||||
.primary / .accent gefüllt in Akzentfarbe
|
||||
.toolbar kleiner, Fließtextschrift — für Werkzeugleisten
|
||||
.ghost ohne Rahmen, Akzentfarbe
|
||||
.flat ohne Rahmen, Textfarbe (Schließen, Einklappen)
|
||||
.icon quadratisch, ohne Innenabstand
|
||||
.accent bleibt neben .primary bestehen: Die noch nicht überarbeiteten Dialoge
|
||||
verwenden diesen Namen. -->
|
||||
<ControlTheme x:Key="{x:Type Button}" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
RecognizesAccessKey="True" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource PressedBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
<Setter Property="Cursor" Value="Arrow" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.primary, ^.accent">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
^.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent600Brush}" />
|
||||
</Style>
|
||||
<Style Selector="^.primary:pressed /template/ ContentPresenter#PART_ContentPresenter,
|
||||
^.accent:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent700Brush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.toolbar">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.ghost">
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.flat">
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^.icon">
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- Umschalt-Schaltfläche: dieselbe Form, plus ausgewählter Zustand.
|
||||
Trägt im Entwurf die Segmentleiste über den Aufgaben-Listen. -->
|
||||
<ControlTheme x:Key="{x:Type ToggleButton}" TargetType="ToggleButton">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Padding" Value="14,7" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked">
|
||||
<Setter Property="Foreground" Value="{DynamicResource BgBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- ════════════════════════════ Eingabefelder ════════════════════════════ -->
|
||||
<ControlTheme x:Key="{x:Type TextBox}" TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
|
||||
<Setter Property="SelectionForegroundBrush" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinHeight" Value="32" />
|
||||
<Setter Property="Padding" Value="9,5" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Panel>
|
||||
<Border Name="PART_BorderElement"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
MinHeight="{TemplateBinding MinHeight}" />
|
||||
|
||||
<DockPanel Margin="{TemplateBinding Padding}">
|
||||
<ContentPresenter Content="{TemplateBinding InnerLeftContent}" DockPanel.Dock="Left" />
|
||||
<ContentPresenter Content="{TemplateBinding InnerRightContent}" DockPanel.Dock="Right" />
|
||||
|
||||
<ScrollViewer Name="PART_ScrollViewer"
|
||||
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
|
||||
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
|
||||
<Panel>
|
||||
<!-- Der Platzhalter verschwindet, sobald etwas im Feld steht. Über
|
||||
IsVisible statt Opacity, damit er keine Maus-Ereignisse abfängt. -->
|
||||
<TextBlock Name="PART_Watermark"
|
||||
Text="{TemplateBinding PlaceholderText}"
|
||||
Foreground="{DynamicResource TextGhostBrush}"
|
||||
TextAlignment="{TemplateBinding TextAlignment}"
|
||||
TextWrapping="{TemplateBinding TextWrapping}"
|
||||
IsVisible="{TemplateBinding Text, Converter={x:Static StringConverters.IsNullOrEmpty}}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
|
||||
<TextPresenter Name="PART_TextPresenter"
|
||||
Text="{TemplateBinding Text, Mode=TwoWay}"
|
||||
CaretIndex="{TemplateBinding CaretIndex}"
|
||||
SelectionStart="{TemplateBinding SelectionStart}"
|
||||
SelectionEnd="{TemplateBinding SelectionEnd}"
|
||||
TextAlignment="{TemplateBinding TextAlignment}"
|
||||
TextWrapping="{TemplateBinding TextWrapping}"
|
||||
PasswordChar="{TemplateBinding PasswordChar}"
|
||||
RevealPassword="{TemplateBinding RevealPassword}"
|
||||
SelectionBrush="{TemplateBinding SelectionBrush}"
|
||||
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
|
||||
CaretBrush="{TemplateBinding CaretBrush}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</Panel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
|
||||
<!-- Mehrzeilig: Text oben beginnen lassen, nicht mittig. -->
|
||||
<Style Selector="^[AcceptsReturn=True]">
|
||||
<Setter Property="VerticalContentAlignment" Value="Top" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- ════════════════════════════ Auswahlfeld ════════════════════════════ -->
|
||||
<ControlTheme x:Key="{x:Type ComboBox}" TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinHeight" Value="32" />
|
||||
<Setter Property="Padding" Value="9,5" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="PlaceholderForeground" Value="{DynamicResource TextGhostBrush}" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Border Name="Background" Grid.ColumnSpan="2"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
MinHeight="{TemplateBinding MinHeight}" />
|
||||
|
||||
<TextBlock Name="PlaceholderTextBlock" Grid.Column="0"
|
||||
Text="{TemplateBinding PlaceholderText}"
|
||||
Foreground="{TemplateBinding PlaceholderForeground}"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{TemplateBinding SelectionBoxItem, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
|
||||
<ContentControl Name="ContentPresenter" Grid.Column="0"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding ItemTemplate}"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<ctrl:StrokeIcon Grid.Column="1" Data="{StaticResource IconChevronDown}"
|
||||
Width="12" Height="12" Margin="0,0,9,0"
|
||||
VerticalAlignment="Center" IsHitTestVisible="False" />
|
||||
|
||||
<Popup Name="PART_Popup"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen, Mode=TwoWay}"
|
||||
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
PlacementTarget="Background"
|
||||
IsLightDismissEnabled="True">
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="1" CornerRadius="0" Padding="0,2">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="320">
|
||||
<ItemsPresenter Name="PART_ItemsPresenter"
|
||||
ItemsPanel="{TemplateBinding ItemsPanel}" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ Border#Background">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource TextGhostBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:focus /template/ Border#Background">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<ControlTheme x:Key="{x:Type ComboBoxItem}" TargetType="ComboBoxItem">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource HoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- ════════════════════ Kontrollkästchen & Auswahlknopf ════════════════════
|
||||
|
||||
Beide teilen sich die Form aus dem Entwurf: 16 px Marke, 1.5 px Rand, im
|
||||
gewählten Zustand mit Akzent gefüllt. Unterschied ist allein die Rundung —
|
||||
eckig für Mehrfach-, rund für Einfachauswahl. -->
|
||||
<ControlTheme x:Key="{x:Type CheckBox}" TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
|
||||
<Border Name="Mark" Width="16" Height="16" CornerRadius="3"
|
||||
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
|
||||
Background="Transparent" VerticalAlignment="Center">
|
||||
<ctrl:StrokeIcon Name="Glyph" Width="12" Height="12" StrokeThickness="3"
|
||||
Foreground="{DynamicResource BgBrush}"
|
||||
Data="M4 12l5 5L20 6" IsVisible="False" />
|
||||
</Border>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ Border#Mark">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked /template/ Border#Mark">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked /template/ ctrl|StrokeIcon#Glyph">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="^:indeterminate /template/ Border#Mark">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<ControlTheme x:Key="{x:Type RadioButton}" TargetType="RadioButton">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Background="Transparent">
|
||||
<Border Name="Mark" Width="16" Height="16" CornerRadius="8"
|
||||
BorderBrush="{DynamicResource DividerBrush}" BorderThickness="1.5"
|
||||
Background="Transparent" VerticalAlignment="Center">
|
||||
<!-- Der innere Punkt entsteht wie im Entwurf durch einen Ring in
|
||||
Hintergrundfarbe, nicht durch eine zweite Ellipse. -->
|
||||
<Border Name="Dot" CornerRadius="8" Margin="3"
|
||||
Background="{DynamicResource BgBrush}" IsVisible="False" />
|
||||
</Border>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:pointerover /template/ Border#Mark">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked /template/ Border#Mark">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:checked /template/ Border#Dot">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="^:disabled">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- ════════════════════════════ Listen ════════════════════════════ -->
|
||||
<ControlTheme x:Key="{x:Type ListBox}" TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
|
||||
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
|
||||
<ItemsPresenter Name="PART_ItemsPresenter"
|
||||
ItemsPanel="{TemplateBinding ItemsPanel}"
|
||||
Margin="{TemplateBinding Padding}" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</ControlTheme>
|
||||
|
||||
<ControlTheme x:Key="{x:Type ListBoxItem}" TargetType="ListBoxItem">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="7" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Name="PART_ContentPresenter"
|
||||
Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource RowHoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentRowBrush}" />
|
||||
</Style>
|
||||
<Style Selector="^:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
|
||||
<!-- ════════════════════════════ Rahmen mit Eckmarken ════════════════════════════
|
||||
|
||||
Die vier Marken liegen halb außerhalb des Rahmens; sie sitzen deshalb in einem
|
||||
Grid über dem Rahmen und nicht in ihm. IsHitTestVisible=False, damit sie
|
||||
Klicks auf den Inhalt nicht abfangen. -->
|
||||
<ControlTheme x:Key="{x:Type ctrl:BlueprintFrame}" TargetType="ctrl:BlueprintFrame">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Grid>
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</Border>
|
||||
|
||||
<Panel Width="11" Height="11" Margin="-6,-6,0,0" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top">
|
||||
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
</Panel>
|
||||
<Panel Width="11" Height="11" Margin="0,-6,-6,0" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
</Panel>
|
||||
<Panel Width="11" Height="11" Margin="-6,0,0,-6" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Bottom">
|
||||
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
</Panel>
|
||||
<Panel Width="11" Height="11" Margin="0,0,-6,-6" IsHitTestVisible="False"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom">
|
||||
<Rectangle Width="1" Height="11" Margin="5,0,0,0" HorizontalAlignment="Left"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
<Rectangle Width="11" Height="1" Margin="0,5,0,0" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource TextFaintBrush}" />
|
||||
</Panel>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</ControlTheme>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Styles.Resources>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Grundlagen: Fenster, Schrift, Trenner
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource BgBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="UserControl">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
</Style>
|
||||
|
||||
<!-- Überschriften laufen in Barlow Condensed, Fließtext in Barlow — das ist der
|
||||
auffälligste Zug des Entwurfs und darf nirgends per Hand gesetzt werden. -->
|
||||
<Style Selector="TextBlock.h1">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.h2">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
</Style>
|
||||
|
||||
<!-- Bestandsname aus der ersten Portierung; die noch nicht überarbeiteten Dialoge
|
||||
binden darauf. -->
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource HeadingFont}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<!-- Die kleine Versalzeile über jedem Abschnitt — im Entwurf durchgehend in
|
||||
Akzentfarbe, 10 px, weit gesperrt. -->
|
||||
<Style Selector="TextBlock.kicker">
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="LetterSpacing" Value="1" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,4" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste ueber einer Ansicht -->
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="SelectableTextBlock">
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource AccentSoftBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Log-Stufen. Über Klassen statt über einen Pinsel aus einem Konverter, damit die
|
||||
Farben beim Wechsel auf Dunkel mitziehen. -->
|
||||
<Style Selector="SelectableTextBlock.warn">
|
||||
<Setter Property="Foreground" Value="{DynamicResource WarningBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="SelectableTextBlock.err, SelectableTextBlock.ftl">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Flächen
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Karte: eckig, Haarlinie, durchsichtig — der Entwurf setzt bewusst keine
|
||||
Füllfarbe, damit die Kästen als Zeichnung und nicht als Kacheln wirken. -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<!-- Karte in Akzent hinterlegt — eigene Chat-Nachrichten. -->
|
||||
<Style Selector="Border.card.accent">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="24,6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.topbar">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="24,14" />
|
||||
</Style>
|
||||
|
||||
<!-- Konsolenfläche der Log-Ansicht -->
|
||||
<Style Selector="Border.console">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14" />
|
||||
</Style>
|
||||
|
||||
<!-- Senkrechter Strich zwischen Gruppen einer Werkzeugleiste -->
|
||||
<Style Selector="Border.sep">
|
||||
<Setter Property="Width" Value="1" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="Margin" Value="4,0" />
|
||||
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.hr">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Background" Value="{DynamicResource DividerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Werkzeugleisten
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="StackPanel.toolbar">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="Spacing" Value="6" />
|
||||
<Setter Property="Margin" Value="8" />
|
||||
<Setter Property="Margin" Value="0,0,0,10" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
<Style Selector="WrapPanel.toolbar">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="ItemSpacing" Value="6" />
|
||||
<Setter Property="LineSpacing" Value="6" />
|
||||
<Setter Property="Margin" Value="0,0,0,10" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste am unteren Rand -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Tabellen
|
||||
|
||||
Von Hand statt DataGrid: Der Entwurf zeigt eine Kopfzeile in Versalien und
|
||||
Zeilen mit Haarlinie darunter — mehr nicht, kein Sortieren, kein Spaltenziehen.
|
||||
Ein umgefärbtes DataGrid wäre hier deutlich mehr Aufwand als eine ListBox, und
|
||||
das Ergebnis säße am Ende doch nicht auf dem Raster des Entwurfs.
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Kopfzelle -->
|
||||
<Style Selector="TextBlock.th">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="LetterSpacing" Value="0.9" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Kopfzeile: trägt die Linie, die die Spaltentitel vom Inhalt trennt -->
|
||||
<Style Selector="Border.thead">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="7" />
|
||||
</Style>
|
||||
|
||||
<!-- Zeile: Haarlinie unten, schwächer als die der Kopfzeile -->
|
||||
<Style Selector="Border.tr">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource RowLineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.table > ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!-- Zelltext -->
|
||||
<Style Selector="TextBlock.td">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.td.dim">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSubtleBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Etiketten (Status in Tabellen)
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="Border.tag">
|
||||
<Setter Property="Padding" Value="9,2" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Background" Value="{DynamicResource Neutral100Brush}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.tag > TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Neutral800Brush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.tag.ok">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.tag.ok > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent700Brush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.tag.bad">
|
||||
<Setter Property="Background" Value="{DynamicResource DangerSoftBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.tag.bad > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Seitenleiste
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="Border.sidebar">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,1,0" />
|
||||
</Style>
|
||||
|
||||
<!-- Gruppentitel „Arbeiten“ / „Analyse“ / „System“ -->
|
||||
<Style Selector="TextBlock.navgroup">
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextGhostBrush}" />
|
||||
<Setter Property="Margin" Value="8,0,8,6" />
|
||||
</Style>
|
||||
|
||||
<!-- Navigationszeile: Grundzustand durchsichtig, gewählt mit Akzent hinterlegt.
|
||||
Als Button und nicht als ListBoxItem, damit Tastaturbedienung und Tooltip im
|
||||
eingeklappten Zustand ohne Zusatzarbeit funktionieren. -->
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Padding" Value="10,9" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.nav.selected">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSelectedBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- Eingeklappt: nur das Symbol, mittig -->
|
||||
<Style Selector="Button.nav.collapsed">
|
||||
<Setter Property="Padding" Value="9" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Instanz-Schalter oben in der Seitenleiste — einziger Knopf mit Akzentrahmen -->
|
||||
<Style Selector="Button.instance">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource BodyFont}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Padding" Value="10,9" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════════
|
||||
Teiler zwischen den Bereichen der Agenten-Seite
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="GridSplitter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!-- Waagerechter Teiler: eine sichtbare Leiste mit Griffstrich, wie im Entwurf -->
|
||||
<Style Selector="GridSplitter.horizontal">
|
||||
<Setter Property="Height" Value="10" />
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="Cursor" Value="SizeNorthSouth" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,1">
|
||||
<Rectangle Width="36" Height="2" Fill="{DynamicResource DividerBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Senkrechter Teiler: unsichtbar bis auf den Mauszeiger, wie im Entwurf -->
|
||||
<Style Selector="GridSplitter.vertical">
|
||||
<Setter Property="Width" Value="10" />
|
||||
<Setter Property="Cursor" Value="SizeWestEast" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Farbwerte des Entwurfs (Mockup/…/ClawdDotNet-UI-Mockup.dc.html, Konstanten LIGHT/DARK).
|
||||
|
||||
Warum ThemeDictionaries und nicht zwei Dateien: Avalonia tauscht den passenden Block
|
||||
aus, sobald die ThemeVariant wechselt. Jede Ansicht bindet mit DynamicResource auf
|
||||
einen Namen — der Umschalter in der Kopfzeile ist damit eine einzige Zuweisung und
|
||||
kein Neuaufbau der Oberflaeche.
|
||||
|
||||
Deckkraft-Abstufungen stehen als eigene Buerste da, nicht als Opacity am
|
||||
Steuerelement: Opacity blasst auch alle Kindelemente aus, eine halbdurchsichtige
|
||||
Farbe nur sich selbst.
|
||||
-->
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ══════════════════════════════ HELL ══════════════════════════════ -->
|
||||
<ResourceDictionary x:Key="Light">
|
||||
|
||||
<!-- Grundflaechen -->
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#f2f2f3" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#e9e9ea" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#1d1f20" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5980a6" />
|
||||
<SolidColorBrush x:Key="Accent2Brush" Color="#728fab" />
|
||||
<SolidColorBrush x:Key="DividerBrush" Color="#291d1f20" />
|
||||
|
||||
<Color x:Key="BgColor">#f2f2f3</Color>
|
||||
<Color x:Key="SurfaceColor">#e9e9ea</Color>
|
||||
<Color x:Key="TextColor">#1d1f20</Color>
|
||||
<Color x:Key="AccentColor">#5980a6</Color>
|
||||
|
||||
<!-- Text in Abstufungen: 70 % / 60 % / 55 % / 50 % Deckkraft -->
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#b31d1f20" />
|
||||
<SolidColorBrush x:Key="TextSubtleBrush" Color="#991d1f20" />
|
||||
<SolidColorBrush x:Key="TextFaintBrush" Color="#8c1d1f20" />
|
||||
<SolidColorBrush x:Key="TextGhostBrush" Color="#801d1f20" />
|
||||
|
||||
<!-- Zustaende: aus der Textfarbe abgeleitet, damit sie auf jedem Grund sitzen -->
|
||||
<SolidColorBrush x:Key="HoverBrush" Color="#121d1f20" />
|
||||
<SolidColorBrush x:Key="PressedBrush" Color="#241d1f20" />
|
||||
<SolidColorBrush x:Key="RowHoverBrush" Color="#0a1d1f20" />
|
||||
<SolidColorBrush x:Key="RowLineBrush" Color="#141d1f20" />
|
||||
|
||||
<!-- Akzent, hinterlegt -->
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#265980a6" />
|
||||
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#295980a6" />
|
||||
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f5980a6" />
|
||||
|
||||
<!-- Neutrale Rampe -->
|
||||
<SolidColorBrush x:Key="Neutral100Brush" Color="#f5f5f8" />
|
||||
<SolidColorBrush x:Key="Neutral200Brush" Color="#e7e7ea" />
|
||||
<SolidColorBrush x:Key="Neutral300Brush" Color="#d4d4d7" />
|
||||
<SolidColorBrush x:Key="Neutral400Brush" Color="#b7b7ba" />
|
||||
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
|
||||
<SolidColorBrush x:Key="Neutral600Brush" Color="#7a7a7d" />
|
||||
<SolidColorBrush x:Key="Neutral700Brush" Color="#5d5d60" />
|
||||
<SolidColorBrush x:Key="Neutral800Brush" Color="#424244" />
|
||||
<SolidColorBrush x:Key="Neutral900Brush" Color="#2b2b2d" />
|
||||
|
||||
<!-- Akzent-Rampe -->
|
||||
<SolidColorBrush x:Key="Accent100Brush" Color="#eef6ff" />
|
||||
<SolidColorBrush x:Key="Accent200Brush" Color="#d6ebff" />
|
||||
<SolidColorBrush x:Key="Accent300Brush" Color="#b5d9fd" />
|
||||
<SolidColorBrush x:Key="Accent400Brush" Color="#94bce3" />
|
||||
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
|
||||
<SolidColorBrush x:Key="Accent600Brush" Color="#597ea3" />
|
||||
<SolidColorBrush x:Key="Accent700Brush" Color="#416180" />
|
||||
<SolidColorBrush x:Key="Accent800Brush" Color="#2c455d" />
|
||||
<SolidColorBrush x:Key="Accent900Brush" Color="#1d2d3d" />
|
||||
|
||||
<!-- Zustandsfarben: Statuspunkt, Meldungsleiste, Tabellen-Etiketten -->
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#3f8f5c" />
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#b5822a" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#c0392b" />
|
||||
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2eb3453b" />
|
||||
<SolidColorBrush x:Key="DangerTextBrush" Color="#7a2c25" />
|
||||
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ab5822a" />
|
||||
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ac0392b" />
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
<!-- ══════════════════════════════ DUNKEL ══════════════════════════════ -->
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#1a1c1e" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#232527" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#eef0f1" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7ea3c6" />
|
||||
<SolidColorBrush x:Key="Accent2Brush" Color="#8fa8c2" />
|
||||
<SolidColorBrush x:Key="DividerBrush" Color="#29eef0f1" />
|
||||
|
||||
<Color x:Key="BgColor">#1a1c1e</Color>
|
||||
<Color x:Key="SurfaceColor">#232527</Color>
|
||||
<Color x:Key="TextColor">#eef0f1</Color>
|
||||
<Color x:Key="AccentColor">#7ea3c6</Color>
|
||||
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#b3eef0f1" />
|
||||
<SolidColorBrush x:Key="TextSubtleBrush" Color="#99eef0f1" />
|
||||
<SolidColorBrush x:Key="TextFaintBrush" Color="#8ceef0f1" />
|
||||
<SolidColorBrush x:Key="TextGhostBrush" Color="#80eef0f1" />
|
||||
|
||||
<SolidColorBrush x:Key="HoverBrush" Color="#12eef0f1" />
|
||||
<SolidColorBrush x:Key="PressedBrush" Color="#24eef0f1" />
|
||||
<SolidColorBrush x:Key="RowHoverBrush" Color="#0aeef0f1" />
|
||||
<SolidColorBrush x:Key="RowLineBrush" Color="#14eef0f1" />
|
||||
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#267ea3c6" />
|
||||
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#297ea3c6" />
|
||||
<SolidColorBrush x:Key="AccentRowBrush" Color="#1f7ea3c6" />
|
||||
|
||||
<!-- Rampen gespiegelt: 100 ist im Dunkeln die dunkelste Stufe -->
|
||||
<SolidColorBrush x:Key="Neutral100Brush" Color="#2b2b2d" />
|
||||
<SolidColorBrush x:Key="Neutral200Brush" Color="#424244" />
|
||||
<SolidColorBrush x:Key="Neutral300Brush" Color="#5d5d60" />
|
||||
<SolidColorBrush x:Key="Neutral400Brush" Color="#7a7a7d" />
|
||||
<SolidColorBrush x:Key="Neutral500Brush" Color="#98989b" />
|
||||
<SolidColorBrush x:Key="Neutral600Brush" Color="#b7b7ba" />
|
||||
<SolidColorBrush x:Key="Neutral700Brush" Color="#d4d4d7" />
|
||||
<SolidColorBrush x:Key="Neutral800Brush" Color="#e7e7ea" />
|
||||
<SolidColorBrush x:Key="Neutral900Brush" Color="#f5f5f8" />
|
||||
|
||||
<SolidColorBrush x:Key="Accent100Brush" Color="#1d2d3d" />
|
||||
<SolidColorBrush x:Key="Accent200Brush" Color="#2c455d" />
|
||||
<SolidColorBrush x:Key="Accent300Brush" Color="#416180" />
|
||||
<SolidColorBrush x:Key="Accent400Brush" Color="#597ea3" />
|
||||
<SolidColorBrush x:Key="Accent500Brush" Color="#749dc4" />
|
||||
<SolidColorBrush x:Key="Accent600Brush" Color="#94bce3" />
|
||||
<SolidColorBrush x:Key="Accent700Brush" Color="#b5d9fd" />
|
||||
<SolidColorBrush x:Key="Accent800Brush" Color="#d6ebff" />
|
||||
<SolidColorBrush x:Key="Accent900Brush" Color="#eef6ff" />
|
||||
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#5fb37e" />
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#d1a153" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#e0685a" />
|
||||
<SolidColorBrush x:Key="DangerSoftBrush" Color="#2ee0685a" />
|
||||
<SolidColorBrush x:Key="DangerTextBrush" Color="#f0a79c" />
|
||||
<SolidColorBrush x:Key="WarningSoftBrush" Color="#1ad1a153" />
|
||||
<SolidColorBrush x:Key="DangerBarBrush" Color="#1ae0685a" />
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ══════════════════ Themenunabhaengig ══════════════════ -->
|
||||
|
||||
<!--
|
||||
Barlow Condensed ueber Barlow, beide mitgeliefert statt per Systemname referenziert:
|
||||
Barlow Condensed liegt auf keinem Windows und auf keinem schlanken Linux-Abbild, und
|
||||
ohne die Schrift faellt der halbe Entwurf in sich zusammen.
|
||||
-->
|
||||
<FontFamily x:Key="HeadingFont">avares://ClawdDotNet/Assets/Fonts#Barlow Condensed</FontFamily>
|
||||
<FontFamily x:Key="BodyFont">avares://ClawdDotNet/Assets/Fonts#Barlow</FontFamily>
|
||||
<FontFamily x:Key="MonoFont">Cascadia Mono,Consolas,Menlo,DejaVu Sans Mono,monospace</FontFamily>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClawdDotNet.App;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClawdDotNet.Desktop.ViewModels;
|
||||
|
||||
/// <summary>Eine Nachricht zwischen zwei Agenten.</summary>
|
||||
public sealed class AgentMessageItem
|
||||
{
|
||||
public required string SenderName { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public required DateTime Time { get; init; }
|
||||
|
||||
/// <summary>Links steht Agent A, rechts Agent B — wie im Chat mit dem Benutzer.</summary>
|
||||
public required bool IsFromAgentA { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Ein Agent in den beiden Auswahlfeldern.</summary>
|
||||
public sealed record AgentChoice(string AgentId, string DisplayName)
|
||||
{
|
||||
public override string ToString() => DisplayName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verlauf der Nachrichten zwischen zwei Agenten.
|
||||
///
|
||||
/// <para><strong>Ohne Datenbestand.</strong> Agenten können sich heute schon Nachrichten
|
||||
/// schicken — <c>AgentEngine.SendMessageAsync</c> stellt zu, aber nichts schreibt den
|
||||
/// Wortlaut mit. Der Speicher <c>AgentMessages</c> aus
|
||||
/// <c>docs/Agentenkommunikation-Konzept.md</c> ist noch nicht gebaut.</para>
|
||||
///
|
||||
/// <para>Die Seite steht trotzdem vollständig da: Auswahl, Filterung und Darstellung sind
|
||||
/// fertig, und wenn der Speicher kommt, ist hier genau eine Abfrage zu ergänzen —
|
||||
/// <see cref="LoadMessages"/>. Bis dahin erklärt die Seite ihren Leerzustand, statt als
|
||||
/// leerer Kasten dazustehen.</para>
|
||||
/// </summary>
|
||||
public sealed partial class AgentChatsPageViewModel : PageViewModel
|
||||
{
|
||||
public ObservableCollection<AgentChoice> Agents { get; } = [];
|
||||
public ObservableCollection<AgentMessageItem> Messages { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private AgentChoice? _agentA;
|
||||
|
||||
[ObservableProperty]
|
||||
private AgentChoice? _agentB;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isEmpty = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _emptyText = "";
|
||||
|
||||
public AgentChatsPageViewModel() : this(null) { }
|
||||
|
||||
public AgentChatsPageViewModel(AppHost? host) : base("Agenten-Chats")
|
||||
{
|
||||
if (host is null)
|
||||
{
|
||||
Agents.Add(new AgentChoice("dev-assistant", "Dev Assistant"));
|
||||
Agents.Add(new AgentChoice("social-media", "Social Media Manager"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var agent in host.Instance.Agents)
|
||||
Agents.Add(new AgentChoice(agent.AgentId, agent.DisplayName));
|
||||
}
|
||||
|
||||
AgentA = Agents.FirstOrDefault();
|
||||
AgentB = Agents.Skip(1).FirstOrDefault() ?? AgentA;
|
||||
|
||||
LoadMessages();
|
||||
}
|
||||
|
||||
partial void OnAgentAChanged(AgentChoice? value) => LoadMessages();
|
||||
partial void OnAgentBChanged(AgentChoice? value) => LoadMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Hier kommt die Abfrage hin, sobald der <c>AgentMessages</c>-Speicher steht: alle
|
||||
/// Nachrichten zwischen <see cref="AgentA"/> und <see cref="AgentB"/> in beiden
|
||||
/// Richtungen, nach Zeit sortiert.
|
||||
/// </summary>
|
||||
private void LoadMessages()
|
||||
{
|
||||
Messages.Clear();
|
||||
|
||||
EmptyText = Agents.Count < 2
|
||||
? "Für einen Verlauf braucht es mindestens zwei Agenten in dieser Instanz."
|
||||
: "Noch keine Nachrichten aufgezeichnet. Agenten können sich bereits gegenseitig "
|
||||
+ "erreichen; mitgeschrieben wird der Wortlaut erst, wenn der Nachrichtenspeicher "
|
||||
+ "aus docs/Agentenkommunikation-Konzept.md steht.";
|
||||
|
||||
IsEmpty = Messages.Count == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,12 +12,20 @@ public sealed partial class ToolItemViewModel : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Kurzbeschreibung aus der Werkzeug-Registrierung. Leer, wenn das Werkzeug in dieser
|
||||
/// Instanz gar nicht geladen ist — die Liste der bekannten Namen ist länger als die
|
||||
/// der tatsächlich vorhandenen Werkzeuge.
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isEnabled;
|
||||
|
||||
public ToolItemViewModel(string name, bool isEnabled)
|
||||
public ToolItemViewModel(string name, bool isEnabled, string description = "")
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
_isEnabled = isEnabled;
|
||||
}
|
||||
}
|
||||
@@ -75,7 +84,7 @@ public sealed partial class AgentItemViewModel : ObservableObject
|
||||
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
|
||||
];
|
||||
|
||||
public AgentItemViewModel(AgentConfig config)
|
||||
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
|
||||
{
|
||||
Config = config;
|
||||
_displayName = config.DisplayName;
|
||||
@@ -95,7 +104,9 @@ public sealed partial class AgentItemViewModel : ObservableObject
|
||||
foreach (var tool in KnownTools)
|
||||
{
|
||||
var isEnabled = config.Tools.ContainsKey(tool);
|
||||
var item = new ToolItemViewModel(tool, isEnabled);
|
||||
var description = descriptions?.GetValueOrDefault(tool) ?? "";
|
||||
|
||||
var item = new ToolItemViewModel(tool, isEnabled, description);
|
||||
item.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
|
||||
@@ -128,8 +139,21 @@ public sealed partial class AgentItemViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Welches Feld der Texteditor gerade bearbeitet.</summary>
|
||||
public enum AgentTextField
|
||||
{
|
||||
Identity,
|
||||
Soul
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
|
||||
///
|
||||
/// <para>Der Entwurf ordnet die Seite wieder wie die WinForms-Vorlage in vier Felder an:
|
||||
/// oben Agentenliste und Agenteneinstellungen, unten Werkzeugliste und
|
||||
/// Werkzeugeinstellungen. Für dieses Modell heißt das vor allem: Die Werkzeugeinstellungen
|
||||
/// sind kein eigenes Fenster mehr, sondern hängen als <see cref="ToolSettings"/> an der
|
||||
/// Auswahl in der Werkzeugliste.</para>
|
||||
/// </summary>
|
||||
public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
{
|
||||
@@ -141,9 +165,27 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
[ObservableProperty]
|
||||
private AgentItemViewModel? _selectedAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten
|
||||
/// rechts stehen.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private ToolItemViewModel? _selectedTool;
|
||||
|
||||
/// <summary>
|
||||
/// Die Felder zum ausgewählten Werkzeug. Wird bei jedem Wechsel neu gebaut — das
|
||||
/// Ansichtsmodell trägt die Werte von einem guten Dutzend Werkzeuge, und eines davon
|
||||
/// mit den Werten eines anderen weiterzuverwenden ginge irgendwann schief.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private ToolSettingsViewModel? _toolSettings;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusText = "Bereit";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isRunning;
|
||||
|
||||
public List<string> KnownModels { get; } =
|
||||
[
|
||||
"anthropic/claude-3-5-sonnet",
|
||||
@@ -158,7 +200,12 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
|
||||
|
||||
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
|
||||
public event Func<ToolSettingsViewModel, Task<Dictionary<string, object?>?>>? RequestToolSettingsDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück,
|
||||
/// wurde gespeichert; <c>null</c> heißt Abbruch.
|
||||
/// </summary>
|
||||
public event Func<string, string, Task<string?>>? RequestTextEditorDialog;
|
||||
|
||||
public AgentsPageViewModel() : this(null) { }
|
||||
|
||||
@@ -177,20 +224,55 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in
|
||||
/// <see cref="AgentItemViewModel.KnownTools"/> ist fest verdrahtet und kennt auch
|
||||
/// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die
|
||||
/// Beschreibung eben leer.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, string> ToolDescriptions()
|
||||
=> _host?.Tools.GetAll()
|
||||
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase)
|
||||
?? new Dictionary<string, string>();
|
||||
|
||||
[RelayCommand]
|
||||
private void ReloadAgents()
|
||||
{
|
||||
Agents.Clear();
|
||||
if (_host is null) return;
|
||||
|
||||
var descriptions = ToolDescriptions();
|
||||
|
||||
foreach (var agent in _host.Instance.Agents)
|
||||
{
|
||||
Agents.Add(new AgentItemViewModel(agent));
|
||||
}
|
||||
Agents.Add(new AgentItemViewModel(agent, descriptions));
|
||||
|
||||
SelectedAgent = Agents.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
|
||||
{
|
||||
// Die Werkzeugliste unten gehört zum gewählten Agenten — bei einem Wechsel darf
|
||||
// dort nicht die Auswahl des vorigen stehen bleiben.
|
||||
SelectedTool = value?.AvailableTools.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedToolChanged(ToolItemViewModel? value)
|
||||
{
|
||||
if (SelectedAgent is null || value is null)
|
||||
{
|
||||
ToolSettings = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auch für ein noch nicht zugelassenes Werkzeug sollen die Felder sichtbar sein —
|
||||
// sonst müsste man erst ankreuzen, um zu sehen, was einzustellen wäre.
|
||||
if (!SelectedAgent.Config.Tools.TryGetValue(value.Name, out var config) || config is null)
|
||||
config = new Dictionary<string, object?>();
|
||||
|
||||
ToolSettings = new ToolSettingsViewModel(value.Name, config);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddAgentAsync()
|
||||
{
|
||||
@@ -217,7 +299,7 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
_host.Instance.Agents.Add(newConfig);
|
||||
SaveAgents();
|
||||
|
||||
var item = new AgentItemViewModel(newConfig);
|
||||
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
|
||||
Agents.Add(item);
|
||||
SelectedAgent = item;
|
||||
|
||||
@@ -239,28 +321,118 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
StatusText = $"Agent '{agentId}' entfernt.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weckt den gewählten Agenten von Hand — im Entwurf die Schaltfläche „Jetzt ausf.".
|
||||
/// Nimmt dieselbe Nachricht wie der geplante Lauf, damit der Handlauf zeigt, was der
|
||||
/// geplante täte.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
|
||||
private async Task RunAgentNowAsync()
|
||||
{
|
||||
if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
|
||||
if (_host is null || SelectedAgent is null) return;
|
||||
|
||||
var toolName = toolItem.Name;
|
||||
if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
|
||||
if (_host.Engine is null)
|
||||
{
|
||||
config = new Dictionary<string, object?>();
|
||||
SelectedAgent.Config.Tools[toolName] = config;
|
||||
toolItem.IsEnabled = true;
|
||||
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = new ToolSettingsViewModel(toolName, config);
|
||||
var newConfig = await RequestToolSettingsDialog(vm);
|
||||
var config = SelectedAgent.Config;
|
||||
var message = config.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
|
||||
|
||||
if (newConfig is not null)
|
||||
IsRunning = true;
|
||||
StatusText = $"Führe '{config.DisplayName}' aus…";
|
||||
|
||||
try
|
||||
{
|
||||
SelectedAgent.Config.Tools[toolName] = newConfig;
|
||||
SaveAgents();
|
||||
StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
|
||||
var result = await _host.Engine.RunAsync(
|
||||
config, message, _host.Instance.InstanceId, CancellationToken.None);
|
||||
|
||||
StatusText = $"'{config.DisplayName}' abgeschlossen: {result.Status} "
|
||||
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Lauf fehlgeschlagen: {ex.Message}";
|
||||
_logger?.LogError(ex, "Handlauf des Agenten fehlgeschlagen");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task EditIdentityAsync() => EditTextFieldAsync(AgentTextField.Identity);
|
||||
|
||||
[RelayCommand]
|
||||
private Task EditSoulAsync() => EditTextFieldAsync(AgentTextField.Soul);
|
||||
|
||||
/// <summary>
|
||||
/// Identity und Soul sind lange Texte. Der Entwurf gibt ihnen ein eigenes kleines
|
||||
/// Fenster statt eines Feldes in der Spalte — in einem 60 Pixel hohen Kasten lässt
|
||||
/// sich eine Rollenbeschreibung nicht schreiben.
|
||||
/// </summary>
|
||||
private async Task EditTextFieldAsync(AgentTextField field)
|
||||
{
|
||||
if (SelectedAgent is null || RequestTextEditorDialog is null) return;
|
||||
|
||||
var isIdentity = field == AgentTextField.Identity;
|
||||
var title = isIdentity
|
||||
? $"Identity bearbeiten — {SelectedAgent.DisplayName}"
|
||||
: $"Soul bearbeiten — {SelectedAgent.DisplayName}";
|
||||
|
||||
var current = isIdentity ? SelectedAgent.Identity : SelectedAgent.Soul;
|
||||
|
||||
var edited = await RequestTextEditorDialog(title, current);
|
||||
if (edited is null) return;
|
||||
|
||||
if (isIdentity) SelectedAgent.Identity = edited;
|
||||
else SelectedAgent.Soul = edited;
|
||||
|
||||
SaveAgents();
|
||||
}
|
||||
|
||||
/// <summary>Öffnet den Arbeitsordner des gewählten Agenten.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenAgentFolder()
|
||||
{
|
||||
if (_host is null || SelectedAgent is null) return;
|
||||
|
||||
var path = _host.Directories.GetAgentPath(_host.InstancePath, SelectedAgent.DisplayName);
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
StatusText = $"Ordner nicht gefunden: {path}";
|
||||
return;
|
||||
}
|
||||
|
||||
SystemShell.OpenFolder(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration.
|
||||
///
|
||||
/// <para>Das Ankreuzfeld in der Liste und diese Schaltfläche sind zwei verschiedene
|
||||
/// Dinge: Ersteres lässt das Werkzeug zu, letztere schreibt seine Einstellungen. Wer
|
||||
/// nur ankreuzt, bekommt ein Werkzeug mit Vorgabewerten — das ist gewollt.</para>
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void SaveToolSettings()
|
||||
{
|
||||
if (SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
|
||||
|
||||
ToolSettings.ConfirmCommand.Execute(null);
|
||||
|
||||
if (ToolSettings.Result is not { } config) return;
|
||||
|
||||
SelectedAgent.Config.Tools[SelectedTool.Name] = config;
|
||||
|
||||
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen.
|
||||
SelectedTool.IsEnabled = true;
|
||||
|
||||
SaveAgents();
|
||||
StatusText = $"Einstellungen für '{SelectedTool.Name}' gespeichert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -269,12 +441,46 @@ public sealed partial class AgentsPageViewModel : PageViewModel
|
||||
if (_host is null) return;
|
||||
|
||||
foreach (var agentVm in Agents)
|
||||
{
|
||||
agentVm.ApplyChanges();
|
||||
}
|
||||
|
||||
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
|
||||
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
|
||||
StatusText = "Agenten-Konfiguration gespeichert.";
|
||||
}
|
||||
|
||||
// ─── Teilerstellungen ───
|
||||
//
|
||||
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
|
||||
// Verhaeltnis, kein Pixelwert: Sonst sitzt der Teiler nach einem Wechsel auf einen
|
||||
// anderen Bildschirm an einer voellig anderen Stelle.
|
||||
|
||||
public double SplitVertical => Clamp(_host?.Settings.AppSettings.AgentsSplitVertical ?? 0.55);
|
||||
public double SplitTopList => Clamp(_host?.Settings.AppSettings.AgentsSplitTopList ?? 0.44);
|
||||
public double SplitBottomList => Clamp(_host?.Settings.AppSettings.AgentsSplitBottomList ?? 0.44);
|
||||
|
||||
/// <summary>
|
||||
/// Hält den Teiler in einem Bereich, in dem beide Seiten noch bedienbar sind. Ein
|
||||
/// gespeicherter Wert von 0.02 hätte eine Spalte auf einen Strich zusammengezogen und
|
||||
/// wäre ohne Zurücksetzen nicht mehr aufzuziehen gewesen.
|
||||
/// </summary>
|
||||
private static double Clamp(double ratio)
|
||||
=> double.IsFinite(ratio) ? Math.Clamp(ratio, 0.2, 0.8) : 0.5;
|
||||
|
||||
public void SaveSplits(double vertical, double topList, double bottomList)
|
||||
{
|
||||
if (_host is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _host.Settings.AppSettings;
|
||||
settings.AgentsSplitVertical = Clamp(vertical);
|
||||
settings.AgentsSplitTopList = Clamp(topList);
|
||||
settings.AgentsSplitBottomList = Clamp(bottomList);
|
||||
_host.Settings.Save();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Eine Teilerstellung ist es nicht wert, die Bedienung zu unterbrechen.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -64,6 +66,17 @@ public sealed partial class InfoPageViewModel : PageViewModel
|
||||
LoadAssemblyBuildInfos();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fruehere Menueleiste, Punkt „Bearbeiten > Instanz-Ordner oeffnen". Der Entwurf
|
||||
/// kennt keine Menueleiste mehr; der Befehl sitzt jetzt dort, wo auch der Pfad steht.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void OpenInstanceFolder()
|
||||
{
|
||||
if (!Directory.Exists(InstancePath)) return;
|
||||
SystemShell.OpenFolder(InstancePath);
|
||||
}
|
||||
|
||||
private void LoadAssemblyBuildInfos()
|
||||
{
|
||||
Assemblies.Clear();
|
||||
|
||||
@@ -7,21 +7,52 @@ using CommunityToolkit.Mvvm.Input;
|
||||
namespace ClawdDotNet.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Auswahl der Instanz beim Start — der erste Rückruf, den <c>AppHost</c> erfragt.
|
||||
/// Eine Instanz in der Auswahl.
|
||||
///
|
||||
/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in
|
||||
/// einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
|
||||
/// <para>Das Monogramm und der Punkt stammen aus dem Entwurf. Der Punkt zeigt bewusst
|
||||
/// <em>Bereitschaft</em> und nicht „läuft": Ob eine andere Instanz gerade läuft, weiß
|
||||
/// diese Anwendung nicht — jede Instanz ist ein eigener Prozess, und einen
|
||||
/// instanzübergreifenden Status gibt es noch nicht. Ein grüner Punkt für „läuft" wäre
|
||||
/// hier schlicht geraten.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceChoiceViewModel(InstanceInfo info, bool isCurrent)
|
||||
{
|
||||
public InstanceInfo Info { get; } = info;
|
||||
|
||||
public string InstanceName => Info.InstanceName;
|
||||
public string FolderPath => Info.FolderPath;
|
||||
|
||||
/// <summary>Erste zwei Buchstaben, gross — wie im Entwurf.</summary>
|
||||
public string Monogram => Info.InstanceName.Length >= 2
|
||||
? Info.InstanceName[..2].ToUpperInvariant()
|
||||
: Info.InstanceName.ToUpperInvariant();
|
||||
|
||||
public string Summary => $"{Info.AgentCount} Agenten · API-Key {Info.ApiKeyStatus.ToLowerInvariant()}";
|
||||
|
||||
/// <summary>Ohne API-Key kann die Instanz keine Agenten starten.</summary>
|
||||
public bool IsReady => Info.ApiKeyStatus.Equals("Konfiguriert", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Die gerade laufende Instanz — traegt im Entwurf das Etikett „Aktiv".</summary>
|
||||
public bool IsCurrent { get; } = isCurrent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auswahl der Instanz — beim Start und beim Wechseln aus der laufenden Anwendung.
|
||||
///
|
||||
/// <para>Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster
|
||||
/// in einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
|
||||
/// liegt in der Ansicht, und Rückfragen laufen über <see cref="ConfirmError"/> statt über
|
||||
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.
|
||||
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.</para>
|
||||
/// </summary>
|
||||
public sealed partial class InstancePickerViewModel : ViewModelBase
|
||||
{
|
||||
private readonly InstanceDirectoryManager _directories;
|
||||
private readonly TaskCompletionSource<string?> _completion = new();
|
||||
private readonly string? _currentInstancePath;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
|
||||
private InstanceInfo? _selected;
|
||||
private InstanceChoiceViewModel? _selected;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _newInstanceName = "";
|
||||
@@ -30,16 +61,38 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _errorText = "";
|
||||
|
||||
public ObservableCollection<InstanceInfo> Instances { get; } = [];
|
||||
public ObservableCollection<InstanceChoiceViewModel> Instances { get; } = [];
|
||||
|
||||
/// <summary>Der gewählte Pfad, oder <c>null</c> bei Abbruch.</summary>
|
||||
public Task<string?> Result => _completion.Task;
|
||||
|
||||
public string InstancesDirectory => _directories.InstancesDirectory;
|
||||
|
||||
public InstancePickerViewModel(InstanceDirectoryManager directories)
|
||||
/// <summary>
|
||||
/// Beim Start heisst die Schaltflaeche „Starten", aus der laufenden Anwendung heraus
|
||||
/// „Wechseln" — und dann steht der Hinweis dabei, dass dafuer neu gestartet wird.
|
||||
/// </summary>
|
||||
public bool IsSwitchMode => _currentInstancePath is not null;
|
||||
|
||||
public string ConfirmLabel => IsSwitchMode ? "Wechseln" : "Starten";
|
||||
|
||||
/// <summary>Ueberschrift des Dialogs — nicht aus ConfirmLabel zusammengesetzt,
|
||||
/// sonst stuende dort „Instanz Starten".</summary>
|
||||
public string Title => IsSwitchMode ? "Instanz wechseln" : "Instanz wählen";
|
||||
|
||||
public string Hint => IsSwitchMode
|
||||
? "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten. "
|
||||
+ "Wechseln startet ClawdDotNet mit der gewählten Instanz neu."
|
||||
: "Jede Instanz läuft unabhängig mit eigenen Agenten und Daten.";
|
||||
|
||||
/// <param name="currentInstancePath">
|
||||
/// Pfad der laufenden Instanz, oder <c>null</c> beim Start. Steuert Beschriftung und
|
||||
/// das Etikett „Aktiv" in der Liste.
|
||||
/// </param>
|
||||
public InstancePickerViewModel(InstanceDirectoryManager directories, string? currentInstancePath = null)
|
||||
{
|
||||
_directories = directories;
|
||||
_currentInstancePath = currentInstancePath;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
@@ -49,11 +102,17 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
|
||||
Instances.Clear();
|
||||
|
||||
foreach (var instance in _directories.ListInstances())
|
||||
Instances.Add(instance);
|
||||
{
|
||||
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();
|
||||
Selected ??= Instances.FirstOrDefault(i => i.IsCurrent) ?? Instances.FirstOrDefault();
|
||||
}
|
||||
|
||||
private bool CanStart => Selected is not null;
|
||||
@@ -61,7 +120,16 @@ public sealed partial class InstancePickerViewModel : ViewModelBase
|
||||
[RelayCommand(CanExecute = nameof(CanStart))]
|
||||
private void Start() => _completion.TrySetResult(Selected!.FolderPath);
|
||||
|
||||
/// <summary>Abbruch — der Aufrufer beendet die Anwendung.</summary>
|
||||
/// <summary>Waehlt eine Zeile und bestaetigt in einem Zug — die Schaltflaeche je Zeile.</summary>
|
||||
[RelayCommand]
|
||||
private void Activate(InstanceChoiceViewModel? choice)
|
||||
{
|
||||
if (choice is null) return;
|
||||
Selected = choice;
|
||||
_completion.TrySetResult(choice.FolderPath);
|
||||
}
|
||||
|
||||
/// <summary>Abbruch — der Aufrufer beendet die Anwendung oder schliesst den Dialog.</summary>
|
||||
[RelayCommand]
|
||||
private void Cancel() => _completion.TrySetResult(null);
|
||||
|
||||
|
||||
@@ -1,26 +1,98 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Desktop.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
// Ohne Alias verweist "App" hier auf ClawdDotNet.Desktop.App, die Anwendungsklasse
|
||||
// von Avalonia — und nicht auf die gleichnamige Fachschicht.
|
||||
using AppSettings = ClawdDotNet.App.Settings.AppSettings;
|
||||
|
||||
namespace ClawdDotNet.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist.
|
||||
/// Das Gerüst des Hauptfensters: Seitenleiste, Kopfzeile, Meldungen, Statusleiste.
|
||||
///
|
||||
/// <para>Die Menüleiste ist entfallen. Der Entwurf kennt keine, und ihre Einträge hatten
|
||||
/// ohnehin überall sonst schon einen Platz: Der Instanzwechsel sitzt jetzt als eigener
|
||||
/// Schalter oben in der Seitenleiste, die Ordner-Befehle stehen dort, wo die Ordner
|
||||
/// gebraucht werden (Logs-Seite, Info-Seite), und „Ansicht" war eine zweite Navigation
|
||||
/// neben der Navigation.</para>
|
||||
/// </summary>
|
||||
public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly AppHost? _host;
|
||||
|
||||
/// <summary>Breite der Seitenleiste, ausgeklappt und eingeklappt (siehe Entwurf).</summary>
|
||||
private const double SidebarWidthExpanded = 240;
|
||||
private const double SidebarWidthCollapsed = 64;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PageTitle))]
|
||||
private PageViewModel? _selectedPage;
|
||||
|
||||
public ObservableCollection<PageViewModel> Pages { get; }
|
||||
|
||||
/// <summary>Text der Statusleiste am unteren Rand.</summary>
|
||||
[ObservableProperty]
|
||||
private string _statusText = "Bereit";
|
||||
[NotifyPropertyChangedFor(nameof(SidebarWidth))]
|
||||
[NotifyPropertyChangedFor(nameof(IsSidebarExpanded))]
|
||||
private bool _isSidebarCollapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ThemeLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ThemeIconKey))]
|
||||
private bool _isDarkTheme;
|
||||
|
||||
// ─── Statusleiste ───
|
||||
|
||||
[ObservableProperty]
|
||||
private string _instanceName = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isApiReachable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _apiStatusText = "unbekannt";
|
||||
|
||||
/// <summary>Kurzinfo zu Tokens und Kosten der letzten Stunde bzw. 24 Stunden.</summary>
|
||||
[ObservableProperty]
|
||||
private string _usageText = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _usageTooltip = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _creditsText = "—";
|
||||
|
||||
/// <summary>Alle Bereiche, in der Reihenfolge der Navigation.</summary>
|
||||
public ObservableCollection<PageViewModel> Pages { get; } = [];
|
||||
|
||||
/// <summary>Die Navigation, gruppiert wie im Entwurf.</summary>
|
||||
public ObservableCollection<NavGroupViewModel> NavGroups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Instanzübergreifende Hinweise. Bleibt vorerst leer — der Dienst, der Fehler und
|
||||
/// aufgebrauchte Guthaben <em>anderer</em> Instanzen sieht, existiert noch nicht
|
||||
/// (siehe Implementierungsleitfaden im Mockup-Verzeichnis). Die Leiste blendet sich
|
||||
/// aus, solange nichts anliegt, und ist damit heute schon unauffällig richtig.
|
||||
/// </summary>
|
||||
public ObservableCollection<NotificationViewModel> Notifications { get; } = [];
|
||||
|
||||
public bool HasNotifications => Notifications.Count > 0;
|
||||
|
||||
public double SidebarWidth => IsSidebarCollapsed ? SidebarWidthCollapsed : SidebarWidthExpanded;
|
||||
|
||||
public bool IsSidebarExpanded => !IsSidebarCollapsed;
|
||||
|
||||
public string PageTitle => SelectedPage?.Title ?? "";
|
||||
|
||||
public string ThemeLabel => IsDarkTheme ? "Dunkelmodus" : "Hellmodus";
|
||||
|
||||
/// <summary>Fusszeile der Seitenleiste.</summary>
|
||||
public string VersionText => $"ClawdDotNet {AppHost.AppVersion} · Avalonia";
|
||||
|
||||
/// <summary>Sonne im Hellmodus, Mond im Dunkelmodus — beides Namen aus Icons.axaml.</summary>
|
||||
public string ThemeIconKey => IsDarkTheme ? "IconMoon" : "IconSun";
|
||||
|
||||
public event Action? RequestExit;
|
||||
public event Action? RequestInstanceManagerDialog;
|
||||
@@ -32,25 +104,171 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
_host = host;
|
||||
|
||||
Pages =
|
||||
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("Arbeiten",
|
||||
[
|
||||
new ChatPageViewModel(host),
|
||||
new LogPageViewModel(host),
|
||||
new AgentsPageViewModel(host),
|
||||
new SettingsPageViewModel(host),
|
||||
new TasksPageViewModel(host),
|
||||
new BackupPageViewModel(host),
|
||||
new InfoPageViewModel(host)
|
||||
];
|
||||
new NavItemViewModel(this, chat, "IconChat"),
|
||||
new NavItemViewModel(this, agents, "IconAgents"),
|
||||
new NavItemViewModel(this, tasks, "IconTasks")
|
||||
]));
|
||||
|
||||
SelectedPage = Pages[0];
|
||||
NavGroups.Add(new NavGroupViewModel("Analyse",
|
||||
[
|
||||
new NavItemViewModel(this, tokens, "IconChart"),
|
||||
new NavItemViewModel(this, agentChats, "IconAgentChats")
|
||||
]));
|
||||
|
||||
StatusText = host is null
|
||||
? "Entwurfsmodus"
|
||||
: $"Instanz: {host.Instance.InstanceName} · "
|
||||
+ (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit");
|
||||
NavGroups.Add(new NavGroupViewModel("System",
|
||||
[
|
||||
new NavItemViewModel(this, backup, "IconBackup"),
|
||||
new NavItemViewModel(this, logs, "IconLogs"),
|
||||
new NavItemViewModel(this, settings, "IconSettings"),
|
||||
new NavItemViewModel(this, info, "IconInfo")
|
||||
]));
|
||||
|
||||
// Die Leiste blendet sich aus, solange nichts anliegt — dafür muss die Ansicht
|
||||
// von Zu- und Abgängen erfahren, nicht nur von der Sammlung selbst.
|
||||
Notifications.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasNotifications));
|
||||
|
||||
SelectedPage = chat;
|
||||
|
||||
if (host is null)
|
||||
{
|
||||
InstanceName = "Entwurfsmodus";
|
||||
ApiStatusText = "Entwurf";
|
||||
UsageText = "1h: 3.240 Tok · 24h: 168.500 Tok";
|
||||
CreditsText = "Credits: $41.86 von $100.00";
|
||||
|
||||
// Zwei Beispielmeldungen, damit die Leiste im Editor sichtbar ist.
|
||||
Notifications.Add(new NotificationViewModel(
|
||||
this, isError: true, "sandbox", "OpenRouter-Guthaben aufgebraucht — Agenten pausiert."));
|
||||
Notifications.Add(new NotificationViewModel(
|
||||
this, isError: false, "stock-team", "3 unbehandelte Exceptions in den letzten 15 Minuten."));
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceName = host.Instance.InstanceName;
|
||||
|
||||
IsSidebarCollapsed = host.Settings.AppSettings.SidebarCollapsed;
|
||||
IsDarkTheme = ThemeManager.IsDarkName(host.Settings.AppSettings.Theme);
|
||||
ThemeManager.Apply(IsDarkTheme);
|
||||
ApplyCollapsedToNav();
|
||||
|
||||
HookStatusService(host);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hängt die Statusleiste an den OpenRouter-Dienst.
|
||||
///
|
||||
/// <para>Der Dienst war fertig und ungenutzt — er lieferte seine Texte bislang an
|
||||
/// niemanden. <c>OnStatusUpdated</c> kommt vom Hintergrundfaden des
|
||||
/// <c>PeriodicTimer</c>, deshalb der Wechsel auf den Oberflächenfaden.</para>
|
||||
/// </summary>
|
||||
private void HookStatusService(AppHost host)
|
||||
{
|
||||
if (host.Status is not { } status)
|
||||
{
|
||||
ApiStatusText = "kein API-Key";
|
||||
UsageText = "keine Nutzungsdaten";
|
||||
CreditsText = "";
|
||||
return;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
IsApiReachable = status.IsApiReachable;
|
||||
ApiStatusText = status.IsApiReachable ? "OK" : "nicht erreichbar";
|
||||
UsageText = status.CreditsText;
|
||||
UsageTooltip = status.CreditsTooltip;
|
||||
|
||||
CreditsText = status.CreditRemaining is { } remaining && status.CreditBalance is { } total
|
||||
? $"Credits: ${remaining:F2} von ${total:F2}"
|
||||
: "Credits: —";
|
||||
}
|
||||
|
||||
status.OnStatusUpdated += () => Dispatcher.UIThread.Post(Update);
|
||||
Update();
|
||||
}
|
||||
|
||||
partial void OnSelectedPageChanged(PageViewModel? value)
|
||||
{
|
||||
foreach (var item in NavGroups.SelectMany(g => g.Items))
|
||||
item.IsSelected = ReferenceEquals(item.Page, value);
|
||||
}
|
||||
|
||||
partial void OnIsSidebarCollapsedChanged(bool value)
|
||||
{
|
||||
ApplyCollapsedToNav();
|
||||
Persist(s => s.SidebarCollapsed = value);
|
||||
}
|
||||
|
||||
partial void OnIsDarkThemeChanged(bool value)
|
||||
{
|
||||
ThemeManager.Apply(value);
|
||||
Persist(s => s.Theme = ThemeManager.ToName(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der eingeklappte Zustand steht an jedem Eintrag statt an einer Bindung auf das
|
||||
/// Elternfenster: Aus einer DataTemplate heraus zurück auf das Fenster zu binden ist
|
||||
/// mit übersetzten Bindungen jedes Mal ein Ausdruck, den niemand mehr liest.
|
||||
/// </summary>
|
||||
private void ApplyCollapsedToNav()
|
||||
{
|
||||
foreach (var group in NavGroups)
|
||||
{
|
||||
group.IsTitleVisible = !IsSidebarCollapsed;
|
||||
|
||||
foreach (var item in group.Items)
|
||||
item.IsCollapsed = IsSidebarCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen sind ein Nebeneffekt der Bedienung, kein eigener Vorgang: Wer die
|
||||
/// Leiste einklappt, drückt nicht anschließend auf Speichern. Ein Schreibfehler darf
|
||||
/// die Bedienung deshalb auch nicht unterbrechen.
|
||||
/// </summary>
|
||||
private void Persist(Action<AppSettings> change)
|
||||
{
|
||||
if (_host is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
change(_host.Settings.AppSettings);
|
||||
_host.Settings.Save();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Der Zustand gilt für diese Sitzung trotzdem — nur eben nicht für die nächste.
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPage(PageViewModel? page)
|
||||
{
|
||||
if (page is not null) SelectedPage = page;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleSidebar() => IsSidebarCollapsed = !IsSidebarCollapsed;
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleTheme() => IsDarkTheme = !IsDarkTheme;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInstanceFolder()
|
||||
{
|
||||
@@ -59,31 +277,62 @@ public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenLogFolder()
|
||||
{
|
||||
if (_host is null) return;
|
||||
SystemShell.OpenFolder(_host.LogDirectory);
|
||||
}
|
||||
private void OpenInstanceManager() => RequestInstanceManagerDialog?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInstanceManager()
|
||||
{
|
||||
RequestInstanceManagerDialog?.Invoke();
|
||||
}
|
||||
private void Exit() => RequestExit?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Eine Gruppe der Navigation („Arbeiten", „Analyse", „System").</summary>
|
||||
public sealed partial class NavGroupViewModel(string title, IReadOnlyList<NavItemViewModel> items)
|
||||
: ObservableObject
|
||||
{
|
||||
public string Title { get; } = title;
|
||||
public IReadOnlyList<NavItemViewModel> Items { get; } = items;
|
||||
|
||||
/// <summary>Im eingeklappten Zustand entfällt die Gruppenüberschrift.</summary>
|
||||
[ObservableProperty]
|
||||
private bool _isTitleVisible = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag der Navigation.
|
||||
///
|
||||
/// <para>Der Befehl sitzt am Eintrag selbst und nicht am Fenster: Aus einer DataTemplate
|
||||
/// heraus über <c>$parent[…]</c> auf einen Befehl des Fensters zu binden funktioniert
|
||||
/// zwar, ist aber bei übersetzten Bindungen ein Ausdruck mit Typumwandlung, den man beim
|
||||
/// nächsten Lesen erst wieder entziffern muss.</para>
|
||||
/// </summary>
|
||||
public sealed partial class NavItemViewModel(
|
||||
MainWindowViewModel owner, PageViewModel page, string iconKey) : ObservableObject
|
||||
{
|
||||
public PageViewModel Page { get; } = page;
|
||||
public string Title => Page.Title;
|
||||
|
||||
/// <summary>Name der Geometrie in <c>Styles/Icons.axaml</c>.</summary>
|
||||
public string IconKey { get; } = iconKey;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSelected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isCollapsed;
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPageByTitle(string title)
|
||||
{
|
||||
var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
|
||||
if (target is not null)
|
||||
SelectedPage = target;
|
||||
}
|
||||
private void Select() => owner.SelectedPage = Page;
|
||||
}
|
||||
|
||||
/// <summary>Eine Zeile der Benachrichtigungsleiste.</summary>
|
||||
public sealed partial class NotificationViewModel(
|
||||
MainWindowViewModel owner, bool isError, string instanceName, string text) : ObservableObject
|
||||
{
|
||||
public bool IsError { get; } = isError;
|
||||
public bool IsWarning => !IsError;
|
||||
public string InstanceName { get; } = instanceName;
|
||||
public string Text { get; } = text;
|
||||
|
||||
[RelayCommand]
|
||||
private void Exit()
|
||||
{
|
||||
RequestExit?.Invoke();
|
||||
}
|
||||
private void Dismiss() => owner.Notifications.Remove(this);
|
||||
}
|
||||
|
||||
/// <summary>Ein Bereich des Hauptfensters.</summary>
|
||||
@@ -93,7 +342,7 @@ public abstract partial class PageViewModel(string title) : ViewModelBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platzhalter, solange der Bereich noch nicht portiert ist.
|
||||
/// Platzhalter, solange ein Bereich noch nicht portiert ist.
|
||||
/// </summary>
|
||||
public sealed partial class PlaceholderPageViewModel(string title, string note)
|
||||
: PageViewModel(title)
|
||||
|
||||
@@ -26,6 +26,14 @@ public sealed record JobDisplayItemViewModel(
|
||||
string Status
|
||||
);
|
||||
|
||||
/// <summary>Die drei Ansichten der Aufgaben-Seite.</summary>
|
||||
public enum TasksTab
|
||||
{
|
||||
Jobs,
|
||||
Services,
|
||||
History
|
||||
}
|
||||
|
||||
public sealed record ServiceDisplayItemViewModel(
|
||||
string ServiceId,
|
||||
string Name,
|
||||
@@ -61,6 +69,22 @@ public sealed partial class TasksPageViewModel : PageViewModel
|
||||
[ObservableProperty]
|
||||
private bool _isRunningJob;
|
||||
|
||||
/// <summary>
|
||||
/// Der Entwurf ersetzt die Registerkarten durch eine Segmentleiste. Fachlich ist das
|
||||
/// dasselbe: eine von drei Ansichten. Die drei Kennzeichen darunter gibt es, weil
|
||||
/// eine Bindung „Ist dieser Wert gleich Services?" in AXAML sonst jedes Mal einen
|
||||
/// Konverter mit Parameter bräuchte.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsJobsTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsServicesTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsHistoryTab))]
|
||||
private TasksTab _selectedTab = TasksTab.Jobs;
|
||||
|
||||
public bool IsJobsTab => SelectedTab == TasksTab.Jobs;
|
||||
public bool IsServicesTab => SelectedTab == TasksTab.Services;
|
||||
public bool IsHistoryTab => SelectedTab == TasksTab.History;
|
||||
|
||||
public event Func<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
|
||||
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? RequestAddServiceDialog;
|
||||
|
||||
@@ -429,6 +453,85 @@ public sealed partial class TasksPageViewModel : PageViewModel
|
||||
RefreshServiceList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue
|
||||
/// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste.
|
||||
///
|
||||
/// <para>Gilt nur für Tool-Jobs: <c>SchedulerConfig</c> hat keinen solchen Schalter,
|
||||
/// ein Agent-Wakeup existiert oder existiert nicht. Ihn hier zu ergänzen wäre keine
|
||||
/// Oberflächenarbeit — der Scanner müsste den Schalter erst einmal lesen, sonst liefe
|
||||
/// ein „deaktivierter" Wakeup munter weiter.</para>
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ToggleJobEnabled()
|
||||
{
|
||||
if (_host is null || SelectedJob is null) return;
|
||||
|
||||
var entry = SelectedJob;
|
||||
|
||||
if (entry.JobType != "Tool Job")
|
||||
{
|
||||
StatusText = "Nur Tool-Jobs lassen sich abschalten. "
|
||||
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.";
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
|
||||
var job = agent?.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
|
||||
if (job is null) return;
|
||||
|
||||
job.Enabled = !job.Enabled;
|
||||
SaveInstanceConfig();
|
||||
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);
|
||||
|
||||
StatusText = job.Enabled
|
||||
? $"Job '{entry.TaskMessage}' aktiviert."
|
||||
: $"Job '{entry.TaskMessage}' deaktiviert.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aktiviert bzw. deaktiviert einen Dienst.
|
||||
///
|
||||
/// <para>Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt:
|
||||
/// Umgelegt wird das Kennzeichen <c>Enabled</c> in der Instanzkonfiguration. Ein
|
||||
/// laufender Dienstprozess wird davon nicht angehalten — eine Schaltfläche „Stop",
|
||||
/// nach der etwas weiterläuft, wäre schlimmer als gar keine.</para>
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ToggleServiceEnabled()
|
||||
{
|
||||
if (_host is null || SelectedService is null) return;
|
||||
|
||||
var id = SelectedService.ServiceId;
|
||||
var service = _host.Instance.Services.FirstOrDefault(s => s.ServiceId == id);
|
||||
if (service is null) return;
|
||||
|
||||
service.Enabled = !service.Enabled;
|
||||
SaveInstanceConfig();
|
||||
RefreshServiceList();
|
||||
|
||||
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
|
||||
|
||||
StatusText = service.Enabled
|
||||
? $"Dienst '{service.Name}' aktiviert."
|
||||
: $"Dienst '{service.Name}' deaktiviert.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectTab(string? tab)
|
||||
{
|
||||
SelectedTab = tab switch
|
||||
{
|
||||
"Services" => TasksTab.Services,
|
||||
"History" => TasksTab.History,
|
||||
_ => TasksTab.Jobs
|
||||
};
|
||||
}
|
||||
|
||||
private static string CalculateNextRun(string? cronExpr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClawdDotNet.Desktop.ViewModels;
|
||||
|
||||
/// <summary>Ein Balken des Wochendiagramms.</summary>
|
||||
public sealed class TokenDayItem
|
||||
{
|
||||
public required string DayLabel { get; init; }
|
||||
public required string TokensLabel { get; init; }
|
||||
public required int Tokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Höhe in Bildpunkten, nicht in Prozent: Ein Balken kann sich nicht auf die Höhe
|
||||
/// seines Elternteils beziehen, ohne dass die Zeile eine feste Höhe bekommt — und
|
||||
/// eine feste Höhe im Ansichtsmodell auszurechnen ist ehrlicher als eine, die in
|
||||
/// drei Bindungen verteilt steht.
|
||||
/// </summary>
|
||||
public required double BarHeight { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Eine Zeile der Aufschlüsselung nach Agent und Modell.</summary>
|
||||
public sealed class TokenAgentItem
|
||||
{
|
||||
public required string AgentName { get; init; }
|
||||
public required string Model { get; init; }
|
||||
public required string TokensLabel { get; init; }
|
||||
public required string CostLabel { get; init; }
|
||||
public required int Runs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tokennutzung der letzten sieben Tage.
|
||||
///
|
||||
/// <para>Liest aus <see cref="SqliteUsageRepository"/> — dieselbe Quelle, aus der die
|
||||
/// Tagesbudgets bedient werden. Kein Diagramm-Paket: Der Entwurf zeigt sieben Balken und
|
||||
/// eine Tabelle, und dafür genügen Rechtecke mit ausgerechneter Höhe. Eine Bibliothek
|
||||
/// dafür wäre 8 MB Abhängigkeit für sieben Rechtecke.</para>
|
||||
/// </summary>
|
||||
public sealed partial class TokenUsagePageViewModel : PageViewModel
|
||||
{
|
||||
/// <summary>Höhe der Diagrammfläche in Bildpunkten (siehe Entwurf: 160 px inkl. Beschriftung).</summary>
|
||||
private const double ChartHeight = 130;
|
||||
|
||||
/// <summary>Wie viele Läufe für die Aufschlüsselung herangezogen werden.</summary>
|
||||
private const int RecentRunLimit = 1000;
|
||||
|
||||
private const int Days = 7;
|
||||
|
||||
private readonly AppHost? _host;
|
||||
|
||||
public ObservableCollection<TokenDayItem> Days7 { get; } = [];
|
||||
public ObservableCollection<TokenAgentItem> ByAgent { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string _totalTokens = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _totalCost = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _totalRuns = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isEmpty = true;
|
||||
|
||||
public TokenUsagePageViewModel() : this(null) { }
|
||||
|
||||
public TokenUsagePageViewModel(AppHost? host) : base("Tokennutzung")
|
||||
{
|
||||
_host = host;
|
||||
|
||||
if (host?.Usage is null)
|
||||
{
|
||||
StatusText = host is null
|
||||
? "Entwurfsmodus"
|
||||
: "Keine Verbrauchsdatenbank für diese Instanz.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Nicht abwarten: Der Aufbau des Fensters darf nicht auf eine Datenbankabfrage
|
||||
// warten. Die Seite füllt sich, sobald die Zahlen da sind.
|
||||
_ = RefreshAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_host?.Usage is not { } usage) return;
|
||||
|
||||
try
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var daily = new List<DailyUsage>(Days);
|
||||
|
||||
for (var offset = Days - 1; offset >= 0; offset--)
|
||||
daily.Add(await usage.GetDailyAsync(today.AddDays(-offset), "", CancellationToken.None));
|
||||
|
||||
var recent = await usage.GetRecentAsync(RecentRunLimit, CancellationToken.None);
|
||||
|
||||
var since = DateTime.Now.Date.AddDays(-(Days - 1));
|
||||
var window = recent.Where(r => r.OccurredAt >= since).ToArray();
|
||||
|
||||
BuildChart(daily);
|
||||
BuildBreakdown(window);
|
||||
BuildTotals(daily, window);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Verbrauchsdaten konnten nicht gelesen werden: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildChart(IReadOnlyList<DailyUsage> daily)
|
||||
{
|
||||
Days7.Clear();
|
||||
|
||||
// Ohne Höchstwert gäbe es keinen Maßstab; ohne die Untergrenze 1 eine Division
|
||||
// durch null an dem Tag, an dem noch nichts gelaufen ist.
|
||||
var max = Math.Max(1, daily.Max(d => d.TotalTokens));
|
||||
|
||||
foreach (var day in daily)
|
||||
{
|
||||
Days7.Add(new TokenDayItem
|
||||
{
|
||||
DayLabel = day.Date.ToString("ddd"),
|
||||
Tokens = day.TotalTokens,
|
||||
TokensLabel = day.TotalTokens >= 1000
|
||||
? $"{day.TotalTokens / 1000d:0.#}k"
|
||||
: day.TotalTokens.ToString("N0"),
|
||||
|
||||
// Mindestens ein Pixel, damit ein Tag mit wenigen Tokens nicht wie ein
|
||||
// Tag ohne Läufe aussieht.
|
||||
BarHeight = Math.Max(1, day.TotalTokens / (double)max * ChartHeight)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildBreakdown(IReadOnlyList<RunUsage> runs)
|
||||
{
|
||||
ByAgent.Clear();
|
||||
|
||||
var names = _host?.Instance.Agents
|
||||
.ToDictionary(a => a.AgentId, a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
?? [];
|
||||
|
||||
var groups = runs
|
||||
.GroupBy(r => (r.AgentId, r.Model))
|
||||
.OrderByDescending(g => g.Sum(r => r.PromptTokens + r.CompletionTokens));
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var (agentId, model) = group.Key;
|
||||
var tokens = group.Sum(r => r.PromptTokens + r.CompletionTokens);
|
||||
var cost = group.Sum(r => r.CostUsd);
|
||||
var incomplete = group.Any(r => !r.CostIsKnown);
|
||||
|
||||
ByAgent.Add(new TokenAgentItem
|
||||
{
|
||||
AgentName = names.TryGetValue(agentId, out var name) ? name : agentId,
|
||||
Model = model,
|
||||
TokensLabel = tokens.ToString("N0"),
|
||||
CostLabel = FormatCost(cost, incomplete),
|
||||
Runs = group.Count()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildTotals(IReadOnlyList<DailyUsage> daily, IReadOnlyList<RunUsage> runs)
|
||||
{
|
||||
var tokens = daily.Sum(d => d.TotalTokens);
|
||||
var cost = daily.Sum(d => d.CostUsd);
|
||||
var incomplete = daily.Any(d => !d.CostIsComplete);
|
||||
|
||||
TotalTokens = tokens.ToString("N0");
|
||||
TotalCost = FormatCost(cost, incomplete);
|
||||
TotalRuns = daily.Sum(d => d.RunCount).ToString("N0");
|
||||
|
||||
IsEmpty = tokens == 0 && runs.Count == 0;
|
||||
StatusText = IsEmpty ? "In den letzten sieben Tagen ist kein Lauf verzeichnet." : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kosten in Euro, wie in der Statusleiste — mit demselben Kurs aus
|
||||
/// <see cref="OpenRouterStatusService.UsdToEur"/>. Das Zeichen ⚠ steht für Läufe,
|
||||
/// deren Modellpreise nicht vorlagen: Die Summe ist dann zu niedrig, und das soll man
|
||||
/// ihr ansehen.
|
||||
/// </summary>
|
||||
private static string FormatCost(decimal usd, bool incomplete)
|
||||
{
|
||||
var eur = (double)usd * OpenRouterStatusService.UsdToEur;
|
||||
return incomplete ? $"{eur:F2} € ⚠" : $"{eur:F2} €";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
x:Class="ClawdDotNet.Desktop.Views.AgentChatsPageView"
|
||||
x:DataType="vm:AgentChatsPageViewModel">
|
||||
|
||||
<DockPanel MaxWidth="820" HorizontalAlignment="Left">
|
||||
|
||||
<!-- Wer mit wem -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10"
|
||||
Margin="0,0,0,16" VerticalAlignment="Center">
|
||||
<TextBlock Text="Agent A:" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentA}" />
|
||||
|
||||
<TextBlock Text="Agent B:" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0" />
|
||||
<ComboBox Width="220" ItemsSource="{Binding Agents}" SelectedItem="{Binding AgentB}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Leerzustand: erklärt, warum hier noch nichts steht -->
|
||||
<Border Classes="card" DockPanel.Dock="Top" VerticalAlignment="Top"
|
||||
IsVisible="{Binding IsEmpty}">
|
||||
<TextBlock Classes="muted" Text="{Binding EmptyText}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- Verlauf -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" IsVisible="{Binding !IsEmpty}">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="10" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AgentMessageItem">
|
||||
<Border Classes="card" MaxWidth="560"
|
||||
Classes.accent="{Binding !IsFromAgentA}"
|
||||
HorizontalAlignment="{Binding !IsFromAgentA,
|
||||
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
|
||||
<StackPanel Spacing="4">
|
||||
<DockPanel>
|
||||
<TextBlock FontSize="12" FontWeight="SemiBold" Text="{Binding SenderName}" />
|
||||
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
|
||||
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
|
||||
</DockPanel>
|
||||
<SelectableTextBlock FontSize="13.5" TextWrapping="Wrap" Text="{Binding Text}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Views;
|
||||
|
||||
public partial class AgentChatsPageView : UserControl
|
||||
{
|
||||
public AgentChatsPageView() => InitializeComponent();
|
||||
}
|
||||
@@ -3,153 +3,283 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
Padding="16"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="820"
|
||||
x:Class="ClawdDotNet.Desktop.Views.AgentsPageView"
|
||||
x:DataType="vm:AgentsPageViewModel">
|
||||
|
||||
<!--
|
||||
Vier Felder wie in der WinForms-Vorlage: oben Agentenliste und Agenteneinstellungen,
|
||||
unten Werkzeugliste und Werkzeugeinstellungen — aber mit ziehbaren Teilern statt
|
||||
fester SplitterDistance und mit flachen Feldern statt PropertyGrid.
|
||||
|
||||
Die Teilerstellungen werden im Code-Behind gelesen und gespeichert: ColumnDefinition
|
||||
liegt nicht im Sichtbarkeitsbaum und erbt deshalb keinen DataContext — eine Bindung
|
||||
darauf wuerde stillschweigend nichts tun.
|
||||
-->
|
||||
<DockPanel>
|
||||
|
||||
<!-- Statusbar am unteren Rand -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
|
||||
Text="{Binding StatusText}" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Master-Detail Split -->
|
||||
<Grid ColumnDefinitions="260,*">
|
||||
<Grid x:Name="OuterGrid" RowDefinitions="*,Auto,*">
|
||||
|
||||
<!-- Linke Spalte: Agenten-Liste -->
|
||||
<DockPanel Grid.Column="0" Margin="0,0,12,0">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<Button Content="Neu" Command="{Binding AddAgentCommand}" Classes="accent" />
|
||||
<Button Content="Entfernen" Command="{Binding RemoveAgentCommand}" IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="Speichern" Command="{Binding SaveAgentsCommand}" />
|
||||
</StackPanel>
|
||||
<!-- ══════════════════ Oben: Agenten ══════════════════ -->
|
||||
<Grid x:Name="TopGrid" Grid.Row="0" ColumnDefinitions="0.44*,Auto,0.56*">
|
||||
|
||||
<!-- Liste -->
|
||||
<ListBox ItemsSource="{Binding Agents}" SelectedItem="{Binding SelectedAgent}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AgentItemViewModel">
|
||||
<StackPanel Margin="4">
|
||||
<TextBlock Text="{Binding DisplayName}" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding Model}" Classes="caption" />
|
||||
<DockPanel Grid.Column="0" Margin="0,0,14,0">
|
||||
|
||||
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
|
||||
<Button Classes="toolbar" Command="{Binding AddAgentCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
|
||||
<TextBlock Text="Agent" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Button>
|
||||
|
||||
</DockPanel>
|
||||
<Button Classes="toolbar" Command="{Binding RemoveAgentCommand}"
|
||||
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconMinus}" Width="14" Height="14" />
|
||||
<TextBlock Text="Agent" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Rechte Spalte: Agenten-Details -->
|
||||
<ContentControl Grid.Column="1" Content="{Binding SelectedAgent}">
|
||||
<ContentControl.ContentTemplate>
|
||||
<DataTemplate DataType="vm:AgentItemViewModel">
|
||||
<TabControl>
|
||||
<Border Classes="sep" />
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding RunAgentNowCommand}"
|
||||
IsEnabled="{Binding !IsRunning}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
|
||||
<TextBlock Text="Jetzt ausf." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Classes="sep" />
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding EditIdentityCommand}"
|
||||
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
|
||||
<TextBlock Text="Identity" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding EditSoulCommand}"
|
||||
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
|
||||
<TextBlock Text="Soul" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Classes="sep" />
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding OpenAgentFolderCommand}"
|
||||
IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
|
||||
<TextBlock Text="Ordner" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding SaveAgentsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
|
||||
<TextBlock Text="Speichern" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
|
||||
<Border DockPanel.Dock="Top" Classes="thead">
|
||||
<Grid ColumnDefinitions="1.4*,1.6*,2*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="ANZEIGENAME" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="MODELL" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="KURZINFO" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Classes="table" ItemsSource="{Binding Agents}"
|
||||
SelectedItem="{Binding SelectedAgent}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AgentItemViewModel">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="1.4*,1.6*,2*">
|
||||
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
|
||||
Text="{Binding DisplayName}" />
|
||||
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
|
||||
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter x:Name="TopSplitter" Grid.Column="1" Classes="vertical"
|
||||
ResizeDirection="Columns" />
|
||||
|
||||
<!-- Einstellungen des gewählten Agenten -->
|
||||
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto" Padding="4,0,0,0">
|
||||
<ContentControl Content="{Binding SelectedAgent}">
|
||||
<ContentControl.ContentTemplate>
|
||||
<DataTemplate DataType="vm:AgentItemViewModel">
|
||||
<StackPanel Spacing="20" MaxWidth="640" HorizontalAlignment="Left">
|
||||
|
||||
<!-- TAB 1: Allgemein & Modell -->
|
||||
<TabItem Header="Allgemein & Modell">
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="kicker" Text="ALLGEMEIN & MODELL" />
|
||||
<TextBlock Classes="h2" Text="{Binding DisplayName}" />
|
||||
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Agent ID:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding AgentId}" IsReadOnly="True" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anzeigename:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DisplayName}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}" SelectedItem="{Binding Model}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Rolle / Beschreibung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Description}" AcceptsReturn="True" Height="60" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="Prompt Caching:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="4" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}" SelectedItem="{Binding PromptCaching}" Width="150" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Agent-ID" />
|
||||
<TextBox Text="{Binding AgentId}" IsReadOnly="True" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Anzeigename" />
|
||||
<TextBox Text="{Binding DisplayName}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Limits Card -->
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Schutzgrenzen (LoopGuard & Budget)" Classes="heading" />
|
||||
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Max. Schritte pro Run:" />
|
||||
<NumericUpDown Value="{Binding MaxSteps}" Minimum="1" Maximum="100" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="8,0,0,0">
|
||||
<TextBlock Text="Timeout (Sekunden):" />
|
||||
<NumericUpDown Value="{Binding TimeoutSeconds}" Minimum="10" Maximum="3600" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,8,0,0">
|
||||
<TextBlock Text="Max. Kontext-Tokens:" />
|
||||
<NumericUpDown Value="{Binding MaxContextTokens}" Minimum="1000" Maximum="2000000" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="8,8,0,0">
|
||||
<TextBlock Text="Max. Tool-Ergebnis (Zeichen):" />
|
||||
<NumericUpDown Value="{Binding MaxToolResultChars}" Minimum="1000" Maximum="500000" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Modell" />
|
||||
<ComboBox HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}"
|
||||
SelectedItem="{Binding Model}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Prompt Caching" />
|
||||
<ComboBox HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}"
|
||||
SelectedItem="{Binding PromptCaching}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Rolle / Beschreibung" />
|
||||
<TextBox Text="{Binding Description}" AcceptsReturn="True" Height="54"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="System-Prompt (zusätzliche Anweisungen)" />
|
||||
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="70"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB 2: System Prompt & Seele -->
|
||||
<TabItem Header="System-Prompt & Soul">
|
||||
<ScrollViewer Padding="8">
|
||||
<Border Classes="hr" />
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="System-Prompt (Zusätzliche Anweisungen):" FontWeight="Bold" />
|
||||
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="120" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="kicker" Text="GRENZEN" />
|
||||
<TextBlock Classes="h2" Text="Schutzgrenzen (LoopGuard & Budget)" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Identität (Identity):" FontWeight="Bold" />
|
||||
<TextBox Text="{Binding Identity}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Max. Schritte pro Run" />
|
||||
<TextBox Text="{Binding MaxSteps}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Timeout (Sekunden)" />
|
||||
<TextBox Text="{Binding TimeoutSeconds}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Seele & Werte (Soul):" FontWeight="Bold" />
|
||||
<TextBox Text="{Binding Soul}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Max. Kontext-Tokens" />
|
||||
<TextBox Text="{Binding MaxContextTokens}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Max. Tool-Ergebnis (Zeichen)" />
|
||||
<TextBox Text="{Binding MaxToolResultChars}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
|
||||
<TextBox Text="{Binding DailyCostUsd}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Tagesbudget (Tokens)" />
|
||||
<TextBox Text="{Binding DailyTokens}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- TAB 3: Werkzeuge (Tools) -->
|
||||
<TabItem Header="Werkzeuge (Tools)">
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Zugelassene Werkzeuge für diesen Agenten:" Classes="heading" />
|
||||
<ItemsControl ItemsSource="{Binding AvailableTools}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ToolItemViewModel">
|
||||
<Grid ColumnDefinitions="200,Auto" Margin="0,4">
|
||||
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsEnabled}" Grid.Column="0" VerticalAlignment="Center" />
|
||||
<Button Content="⚙ Konfigurieren" Command="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).ConfigureToolCommand}" CommandParameter="{Binding}" IsEnabled="{Binding IsEnabled}" Grid.Column="1" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ContentControl.ContentTemplate>
|
||||
</ContentControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</TabControl>
|
||||
</DataTemplate>
|
||||
</ContentControl.ContentTemplate>
|
||||
</ContentControl>
|
||||
<GridSplitter x:Name="VerticalSplitter" Grid.Row="1" Classes="horizontal"
|
||||
ResizeDirection="Rows" Margin="0,8" />
|
||||
|
||||
<!-- ══════════════════ Unten: Werkzeuge ══════════════════ -->
|
||||
<Grid x:Name="BottomGrid" Grid.Row="2" ColumnDefinitions="0.44*,Auto,0.56*">
|
||||
|
||||
<DockPanel Grid.Column="0" Margin="0,0,14,0">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="kicker" Margin="0,4,0,8"
|
||||
Text="{Binding SelectedAgent.DisplayName,
|
||||
StringFormat='ZUGELASSENE WERKZEUGE FÜR {0}'}" />
|
||||
|
||||
<Border DockPanel.Dock="Top" Classes="thead">
|
||||
<Grid ColumnDefinitions="36,1.4*,2.4*">
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="WERKZEUG" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="BESCHREIBUNG" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Classes="table" ItemsSource="{Binding SelectedAgent.AvailableTools}"
|
||||
SelectedItem="{Binding SelectedTool}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ToolItemViewModel">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="36,1.4*,2.4*">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsEnabled}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="td" FontWeight="SemiBold"
|
||||
Text="{Binding Name}" />
|
||||
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding Description}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter x:Name="BottomSplitter" Grid.Column="1" Classes="vertical"
|
||||
ResizeDirection="Columns" />
|
||||
|
||||
<DockPanel Grid.Column="2" Margin="4,0,0,0">
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="2" Margin="0,4,0,10">
|
||||
<TextBlock Classes="kicker" Text="WERKZEUG-EINSTELLUNGEN" />
|
||||
<DockPanel>
|
||||
<TextBlock Classes="h2" VerticalAlignment="Center"
|
||||
Text="{Binding SelectedTool.Name}" />
|
||||
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar primary"
|
||||
Command="{Binding SaveToolSettingsCommand}"
|
||||
IsEnabled="{Binding ToolSettings, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
|
||||
<TextBlock Text="Übernehmen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ContentControl Content="{Binding ToolSettings}" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,46 +1,101 @@
|
||||
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 AgentsPageViewModel vm)
|
||||
{
|
||||
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
|
||||
vm.RequestToolSettingsDialog += OnRequestToolSettingsDialog;
|
||||
}
|
||||
|
||||
if (DataContext is not AgentsPageViewModel vm) return;
|
||||
|
||||
_viewModel = vm;
|
||||
vm.RequestAddAgentDialog += OnRequestAddAgentDialog;
|
||||
vm.RequestTextEditorDialog += OnRequestTextEditorDialog;
|
||||
|
||||
ApplySavedSplits(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die gespeicherten Teilerstellungen.
|
||||
///
|
||||
/// <para>Von Hand und nicht per Bindung: <see cref="ColumnDefinition"/> und
|
||||
/// <see cref="RowDefinition"/> liegen nicht im Sichtbarkeitsbaum und erben deshalb
|
||||
/// keinen DataContext. Eine Bindung auf ihre Breite bliebe ohne Wirkung — und zwar
|
||||
/// ohne Fehlermeldung.</para>
|
||||
/// </summary>
|
||||
private void ApplySavedSplits(AgentsPageViewModel vm)
|
||||
{
|
||||
SetRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2], vm.SplitTopList);
|
||||
SetRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2], vm.SplitBottomList);
|
||||
|
||||
OuterGrid.RowDefinitions[0].Height = new GridLength(vm.SplitVertical, GridUnitType.Star);
|
||||
OuterGrid.RowDefinitions[2].Height = new GridLength(1 - vm.SplitVertical, GridUnitType.Star);
|
||||
}
|
||||
|
||||
private static void SetRatio(ColumnDefinition first, ColumnDefinition second, double ratio)
|
||||
{
|
||||
first.Width = new GridLength(ratio, GridUnitType.Star);
|
||||
second.Width = new GridLength(1 - ratio, GridUnitType.Star);
|
||||
}
|
||||
|
||||
private void SaveSplits()
|
||||
{
|
||||
if (_viewModel is null) return;
|
||||
|
||||
_viewModel.SaveSplits(
|
||||
RowRatio(OuterGrid.RowDefinitions[0], OuterGrid.RowDefinitions[2]),
|
||||
ColumnRatio(TopGrid.ColumnDefinitions[0], TopGrid.ColumnDefinitions[2]),
|
||||
ColumnRatio(BottomGrid.ColumnDefinitions[0], BottomGrid.ColumnDefinitions[2]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Verhaeltnis wird aus den tatsaechlichen Pixelbreiten gebildet, nicht aus den
|
||||
/// Sternwerten: Der GridSplitter setzt beim Ziehen mitunter absolute Breiten, und
|
||||
/// dann stuenden in den Sternwerten noch die Zahlen von vor dem Zug.
|
||||
/// </summary>
|
||||
private static double ColumnRatio(ColumnDefinition first, ColumnDefinition second)
|
||||
=> Ratio(first.ActualWidth, second.ActualWidth);
|
||||
|
||||
private static double RowRatio(RowDefinition first, RowDefinition second)
|
||||
=> Ratio(first.ActualHeight, second.ActualHeight);
|
||||
|
||||
private static double Ratio(double first, double second)
|
||||
{
|
||||
var total = first + second;
|
||||
return total > 0 ? first / total : 0.5;
|
||||
}
|
||||
|
||||
private async Task<AddAgentResultData?> OnRequestAddAgentDialog(AddAgentViewModel vm)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is Window window)
|
||||
{
|
||||
var dialog = new AddAgentWindow { DataContext = vm };
|
||||
await dialog.ShowDialog(window);
|
||||
return vm.Result;
|
||||
}
|
||||
return null;
|
||||
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<Dictionary<string, object?>?> OnRequestToolSettingsDialog(ToolSettingsViewModel vm)
|
||||
private async Task<string?> OnRequestTextEditorDialog(string title, string text)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is Window window)
|
||||
{
|
||||
var dialog = new ToolSettingsWindow { DataContext = vm };
|
||||
await dialog.ShowDialog(window);
|
||||
return vm.Result;
|
||||
}
|
||||
return null;
|
||||
if (TopLevel.GetTopLevel(this) is not Window window) return null;
|
||||
|
||||
return await TextEditorWindow.ShowAsync(window, title, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,95 +3,147 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
Padding="16"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
|
||||
x:Class="ClawdDotNet.Desktop.Views.BackupPageView"
|
||||
x:DataType="vm:BackupPageViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Statusbar am unteren Rand -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
|
||||
Text="{Binding StatusText}" TextWrapping="Wrap" />
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="24" MaxWidth="820" HorizontalAlignment="Left">
|
||||
|
||||
<TextBlock Text="Sicherung & Wiederherstellung" Classes="heading" FontSize="20" />
|
||||
<!-- ══════════════════ Manuell ══════════════════ -->
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="kicker" Text="MANUELL" />
|
||||
<TextBlock Classes="h2" Text="Sicherung erstellen" />
|
||||
|
||||
<!-- Card: Neue Sicherung erstellen -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Manuelle Sicherung erstellen" Classes="heading" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto">
|
||||
<TextBox Text="{Binding TargetFolder}" Grid.Column="0" PlaceholderText="Zielordner für Sicherungen..." />
|
||||
<Button Content="Ordner wählen" Click="OnBrowseTargetFolder" Grid.Column="1" Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- Zugangsdaten Optionen -->
|
||||
<TextBlock Text="Zugangsdaten (API-Schlüssel, Passwörter):" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<RadioButton Content="Zugangsdaten NICHT mit sichern (ohne Passphrase)" IsChecked="{Binding SecretsExclude}" />
|
||||
<RadioButton Content="Mit Passphrase verschlüsseln" IsChecked="{Binding SecretsPassphrase}" />
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SecretsPassphrase}" Margin="20,0,0,0">
|
||||
<TextBox Text="{Binding Passphrase}" PasswordChar="*" PlaceholderText="Passphrase eingeben" Width="300" HorizontalAlignment="Left" />
|
||||
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="*" PlaceholderText="Passphrase wiederholen" Width="300" HorizontalAlignment="Left" />
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Bottom">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Zielordner" />
|
||||
<TextBox Text="{Binding TargetFolder}"
|
||||
PlaceholderText="Zielordner für Sicherungen…" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Durchsuchen…" Click="OnBrowseTargetFolder"
|
||||
Margin="8,0,0,0" VerticalAlignment="Bottom" />
|
||||
</Grid>
|
||||
|
||||
<!-- Inhalt Optionen -->
|
||||
<CheckBox Content="Protokolle (Logs) mit einschließen" IsChecked="{Binding IncludeLogs}" />
|
||||
<CheckBox Content="Chatverläufe mit einschließen" IsChecked="{Binding IncludeChatHistory}" />
|
||||
|
||||
<Button Content="▶ Sicherung jetzt erstellen" Command="{Binding CreateBackupCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" Classes="accent" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Card: Automatische Sicherung -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Automatische Sicherung" Classes="heading" />
|
||||
|
||||
<CheckBox Content="Automatische tägliche Sicherung aktivieren" IsChecked="{Binding AutoBackupEnabled}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="16" IsEnabled="{Binding AutoBackupEnabled}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="Uhrzeit:" VerticalAlignment="Center" />
|
||||
<TextBox Text="{Binding AutoBackupTime}" Width="80" PlaceholderText="03:00" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="Max. Anzahl behalten:" VerticalAlignment="Center" />
|
||||
<NumericUpDown Value="{Binding KeepCount}" Minimum="1" Maximum="999" Width="120" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="Zugangsdaten" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="18">
|
||||
<RadioButton GroupName="secrets" Content="nicht mitsichern"
|
||||
IsChecked="{Binding SecretsExclude}" />
|
||||
<RadioButton GroupName="secrets" Content="mit Passphrase schützen"
|
||||
IsChecked="{Binding SecretsPassphrase}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Vorhandene Sicherungen List -->
|
||||
<Border Classes="card">
|
||||
<DockPanel Height="260">
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<TextBlock Text="Vorhandene Sicherungen" Classes="heading" VerticalAlignment="Center" />
|
||||
<Button Content="Aktualisieren" Command="{Binding RefreshBackupListCommand}" Margin="12,0,0,0" />
|
||||
<Button Content="Ordner öffnen" Command="{Binding ShowSelectedInFolderCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="Löschen" Command="{Binding DeleteSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="Wiederherstellen..." Command="{Binding RestoreSelectedCommand}" IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" Classes="accent" />
|
||||
<StackPanel Spacing="8" Margin="24,0,0,0" IsVisible="{Binding SecretsPassphrase}">
|
||||
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Width="320"
|
||||
HorizontalAlignment="Left" PlaceholderText="Passphrase eingeben" />
|
||||
<TextBox Text="{Binding PassphraseRepeat}" PasswordChar="•" Width="320"
|
||||
HorizontalAlignment="Left" PlaceholderText="Passphrase wiederholen" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="Umfang" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="18">
|
||||
<CheckBox Content="Chatverläufe" IsChecked="{Binding IncludeChatHistory}" />
|
||||
<CheckBox Content="Protokolle" IsChecked="{Binding IncludeLogs}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Classes="primary" HorizontalAlignment="Left" Margin="0,4,0,0"
|
||||
Command="{Binding CreateBackupCommand}" IsEnabled="{Binding !IsBusy}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
|
||||
<TextBlock Text="Sicherung jetzt erstellen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="hr" />
|
||||
|
||||
<!-- ══════════════════ Automatik ══════════════════ -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="kicker" Text="AUTOMATIK" />
|
||||
<TextBlock Classes="h2" Text="Automatische Sicherung" />
|
||||
|
||||
<CheckBox Content="täglich sichern um" IsChecked="{Binding AutoBackupEnabled}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="24" Margin="24,0,0,0"
|
||||
IsEnabled="{Binding AutoBackupEnabled}">
|
||||
<StackPanel Width="100">
|
||||
<TextBlock Classes="label" Text="Uhrzeit" />
|
||||
<TextBox Text="{Binding AutoBackupTime}" PlaceholderText="03:00" />
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}" AutoGenerateColumns="False" IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Dateiname" Binding="{Binding FileName}" Width="220" />
|
||||
<DataGridTextColumn Header="Erstellt am" Binding="{Binding LastWriteTime, StringFormat='{}{0:g}'}" Width="150" />
|
||||
<DataGridTextColumn Header="Instanz" Binding="{Binding InstanceName}" Width="130" />
|
||||
<DataGridTextColumn Header="Größe" Binding="{Binding SizeFormatted}" Width="100" />
|
||||
<DataGridTextColumn Header="Zugangsdaten" Binding="{Binding SecretsInfo}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<StackPanel Width="160">
|
||||
<TextBlock Classes="label" Text="Aufbewahren" />
|
||||
<TextBox Text="{Binding KeepCount}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="caption" Margin="24,0,0,0" TextWrapping="Wrap"
|
||||
Text="Die Automatik sichert ohne Zugangsdaten — eine gespeicherte Passphrase wäre wirkungslos." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="hr" />
|
||||
|
||||
<!-- ══════════════════ Bestand ══════════════════ -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="kicker" Text="BESTAND" />
|
||||
|
||||
<DockPanel>
|
||||
<TextBlock Classes="h2" Text="Vorhandene Sicherungen" VerticalAlignment="Center" />
|
||||
|
||||
<WrapPanel DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
|
||||
Margin="0">
|
||||
<Button Classes="toolbar" Content="Aktualisieren"
|
||||
Command="{Binding RefreshBackupListCommand}" />
|
||||
<Button Classes="toolbar" Content="Ordner öffnen"
|
||||
Command="{Binding ShowSelectedInFolderCommand}"
|
||||
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Classes="toolbar" Content="Löschen"
|
||||
Command="{Binding DeleteSelectedCommand}"
|
||||
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Classes="toolbar primary" Content="Wiederherstellen…"
|
||||
Command="{Binding RestoreSelectedCommand}"
|
||||
IsEnabled="{Binding SelectedBackup, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</WrapPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="thead">
|
||||
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="DATEINAME" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="ERSTELLT AM" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="INSTANZ" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="GRÖSSE" />
|
||||
<TextBlock Grid.Column="4" Classes="th" Text="ZUGANGSDATEN" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Classes="table" MaxHeight="300" Margin="0,-10,0,0"
|
||||
ItemsSource="{Binding Backups}" SelectedItem="{Binding SelectedBackup}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:BackupItemViewModel">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="2.6*,1.5*,1.3*,0.9*,1.3*">
|
||||
<TextBlock Grid.Column="0" Classes="td" Text="{Binding FileName}" />
|
||||
<TextBlock Grid.Column="1" Classes="td"
|
||||
Text="{Binding LastWriteTime, StringFormat='{}{0:dd.MM.yyyy HH:mm}'}" />
|
||||
<TextBlock Grid.Column="2" Classes="td" Text="{Binding InstanceName}" />
|
||||
<TextBlock Grid.Column="3" Classes="td" Text="{Binding SizeFormatted}" />
|
||||
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding SecretsInfo}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -3,23 +3,19 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
Padding="16"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
x:Class="ClawdDotNet.Desktop.Views.ChatPageView"
|
||||
x:DataType="vm:ChatPageViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<DockPanel MaxWidth="960" HorizontalAlignment="Left">
|
||||
|
||||
<!-- Statusleiste am unteren Rand -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<!-- Top Toolbar -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<TextBlock Text="Agent:" VerticalAlignment="Center" FontWeight="Bold" />
|
||||
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}" MinWidth="200">
|
||||
<!-- Agentenauswahl und Verlaufsbefehle -->
|
||||
<StackPanel DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||
<TextBlock Text="Agent:" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}"
|
||||
Width="220">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayName}" />
|
||||
@@ -27,43 +23,67 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Button Content="Verlauf leeren" Command="{Binding ClearHistoryCommand}" Margin="12,0,0,0" />
|
||||
<Button Content="Abbrechen" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}" />
|
||||
<Border Classes="sep" />
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding ClearHistoryCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
|
||||
<TextBlock Text="Verlauf leeren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding AbortCommand}" IsEnabled="{Binding IsSending}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconStop}" Width="12" Height="12" IsFilled="True" />
|
||||
<TextBlock Text="Abbrechen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Eingabezeile unten -->
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding InputPrompt}" PlaceholderText="Nachricht eingeben... (Strg+Eingabe zum Senden)"
|
||||
AcceptsReturn="True" Height="60" TextWrapping="Wrap" Grid.Column="0" KeyDown="OnInputKeyDown" />
|
||||
<Button Content="Senden" Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
|
||||
Classes="accent" VerticalAlignment="Stretch" Margin="8,0,0,0" Grid.Column="1" Width="90" />
|
||||
<!-- Statuszeile des Bereichs -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,8,0,0"
|
||||
Text="{Binding StatusText}" />
|
||||
|
||||
<!-- Eingabe -->
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<TextBox Grid.Column="0" Text="{Binding InputPrompt}"
|
||||
PlaceholderText="Nachricht eingeben… (Strg+Eingabe zum Senden)"
|
||||
AcceptsReturn="True" Height="60" TextWrapping="Wrap"
|
||||
KeyDown="OnInputKeyDown" />
|
||||
|
||||
<Button Grid.Column="1" Classes="primary" Content="Senden" Width="100"
|
||||
Command="{Binding SendMessageCommand}" IsEnabled="{Binding !IsSending}"
|
||||
VerticalAlignment="Stretch" Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- Nachrichtenverlauf (Mitte) -->
|
||||
<ScrollViewer x:Name="ChatScroller">
|
||||
<!-- Verlauf -->
|
||||
<ScrollViewer x:Name="ChatScroller" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding ChatEntries}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="10" />
|
||||
<StackPanel Spacing="12" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ChatEntryItemViewModel">
|
||||
<!-- Distinct styling: User right-aligned bubble vs Assistant left-aligned card -->
|
||||
<DockPanel HorizontalAlignment="{Binding IsUser, Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
|
||||
<Border Classes="card" CornerRadius="8" MaxWidth="680"
|
||||
Background="{Binding IsUser, Converter={x:Static conv:ChatBubbleBrushConverter.Instance}, TargetNullValue={x:Null}}">
|
||||
<StackPanel Spacing="4">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Sender}" FontWeight="Bold" FontSize="12" />
|
||||
<TextBlock Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" Classes="caption" HorizontalAlignment="Right" Margin="8,0,0,0" />
|
||||
</DockPanel>
|
||||
<!-- Eigene Nachrichten rechts und in Akzent hinterlegt, Antworten links
|
||||
als schlichter Kasten — wie im Entwurf. -->
|
||||
<Border Classes="card" MaxWidth="660"
|
||||
Classes.accent="{Binding IsUser}"
|
||||
HorizontalAlignment="{Binding IsUser,
|
||||
Converter={x:Static conv:ChatAlignmentConverter.Instance}}">
|
||||
<StackPanel Spacing="4">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Sender}" FontWeight="SemiBold" FontSize="12" />
|
||||
<TextBlock Classes="caption" DockPanel.Dock="Right" HorizontalAlignment="Right"
|
||||
Margin="8,0,0,0"
|
||||
Text="{Binding Timestamp, StringFormat='{}{0:HH:mm}'}" />
|
||||
</DockPanel>
|
||||
|
||||
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
<SelectableTextBlock Text="{Binding Text}" TextWrapping="Wrap" FontSize="13.5" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
@@ -3,67 +3,111 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
x:Class="ClawdDotNet.Desktop.Views.InfoPageView"
|
||||
x:DataType="vm:InfoPageViewModel">
|
||||
|
||||
<ScrollViewer Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<!-- Etwas Luft nach aussen: Die Eckmarken des BlueprintFrame ragen ueber den
|
||||
Rahmen hinaus und wuerden sonst am Rand des Bereichs abgeschnitten. -->
|
||||
<StackPanel Spacing="26" Margin="8" MaxWidth="820" HorizontalAlignment="Left">
|
||||
|
||||
<!-- Titel -->
|
||||
<TextBlock Text="Software- & Instanz-Informationen" Classes="heading" FontSize="20" />
|
||||
<!-- Die einzige Karte des Entwurfs mit Eckmarken -->
|
||||
<ctrl:BlueprintFrame MaxWidth="420" HorizontalAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="h2" Text="ClawdDotNet" />
|
||||
<TextBlock Classes="muted" FontSize="13">
|
||||
<Run Text="Version" /><Run Text=" " /><Run Text="{Binding AppVersion}" /><Run Text=" · Avalonia UI · net10.0" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</ctrl:BlueprintFrame>
|
||||
|
||||
<!-- Anwendungs-Details Card -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Anwendung" Classes="heading" />
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="140,*">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version:" FontWeight="Bold" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
|
||||
<!-- Anwendung -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="kicker" Text="ANWENDUNG" />
|
||||
<TextBlock Classes="h2" Text="Build" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Build-Summary:" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}" Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto" >
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Version" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="Build-Zusammenfassung"
|
||||
Margin="0,6,0,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding BuildSummary}"
|
||||
TextWrapping="Wrap" Margin="0,6,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="hr" />
|
||||
|
||||
<!-- Instanz -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="kicker" Text="INSTANZ" />
|
||||
|
||||
<DockPanel>
|
||||
<TextBlock Classes="h2" Text="Aktuelle Instanz" VerticalAlignment="Center" />
|
||||
<Button DockPanel.Dock="Right" HorizontalAlignment="Right" Classes="toolbar"
|
||||
Command="{Binding OpenInstanceFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
|
||||
<TextBlock Text="Instanz-Ordner öffnen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DockPanel>
|
||||
|
||||
<Grid ColumnDefinitions="170,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="label" Text="Name" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="label" Text="ID" Margin="0,6,0,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="mono" Text="{Binding InstanceId}"
|
||||
Margin="0,6,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="label" Text="Pfad" Margin="0,6,0,0" />
|
||||
<SelectableTextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}"
|
||||
TextWrapping="Wrap" Margin="0,6,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Classes="label" Text="Log-Ordner" Margin="0,6,0,0" />
|
||||
<SelectableTextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}"
|
||||
TextWrapping="Wrap" Margin="0,6,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Classes="label" Text="OpenRouter" Margin="0,6,0,0" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,6,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="hr" />
|
||||
|
||||
<!-- Baugruppen -->
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="kicker" Text="MODULE" />
|
||||
<TextBlock Classes="h2" Text="Baugruppen" />
|
||||
|
||||
<Border Classes="thead">
|
||||
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="MODUL" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="BUILD" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="BUILD-DATUM" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="ÄNDERUNGEN" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<!-- Instanz-Details Card -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="140,*">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name:" FontWeight="Bold" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="ID:" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding InstanceId}" Margin="0,4,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Pfad:" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding InstancePath}" Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Log-Ordner:" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LogDirectory}" Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Status:" FontWeight="Bold" Margin="0,4,0,0" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding OpenRouterStatus}" Margin="0,4,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Assembly Build Info Card -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Module & Baugruppen" Classes="heading" />
|
||||
<DataGrid ItemsSource="{Binding Assemblies}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Modul" Binding="{Binding ShortName}" Width="160" />
|
||||
<DataGridTextColumn Header="Build #" Binding="{Binding BuildNumber}" Width="80" />
|
||||
<DataGridTextColumn Header="Build-Datum" Binding="{Binding BuildDate}" Width="160" />
|
||||
<DataGridTextColumn Header="Änderungen" Binding="{Binding Changes}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ItemsControl ItemsSource="{Binding Assemblies}" Margin="0,-10,0,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AssemblyInfoItem">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="1.6*,0.6*,1.4*,3*">
|
||||
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding ShortName}" />
|
||||
<TextBlock Grid.Column="1" Classes="td" Text="{Binding BuildNumber}" />
|
||||
<TextBlock Grid.Column="2" Classes="td dim" Text="{Binding BuildDate}" />
|
||||
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding Changes}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -3,71 +3,121 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:models="using:ClawdDotNet.App.Models"
|
||||
mc:Ignorable="d" d:DesignWidth="720" d:DesignHeight="460"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="560"
|
||||
x:Class="ClawdDotNet.Desktop.Views.InstancePickerWindow"
|
||||
x:DataType="vm:InstancePickerViewModel"
|
||||
Icon="/Assets/app.ico"
|
||||
Title="ClawdDotNet – Instanz wählen"
|
||||
Width="720" Height="460"
|
||||
Title="ClawdDotNet – Instanz"
|
||||
Width="640" Height="560"
|
||||
MinWidth="520" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel Margin="16">
|
||||
<!-- Rahmen mit Eckmarken wie im Entwurf; 12 Punkte Abstand, weil die Marken nach
|
||||
aussen ragen. -->
|
||||
<ctrl:BlueprintFrame Margin="12" Padding="16">
|
||||
<DockPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Top" Classes="heading" Text="Instanz wählen" />
|
||||
|
||||
<TextBlock DockPanel.Dock="Top" Classes="caption" Margin="0,0,0,12"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
|
||||
|
||||
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
|
||||
<TextBlock DockPanel.Dock="Bottom"
|
||||
Margin="0,8,0,0"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ErrorText}"
|
||||
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- Aktionen -->
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto,Auto" Margin="0,12,0,0">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Grid.Column="0">
|
||||
<TextBox Width="200"
|
||||
Watermark="Name der neuen Instanz"
|
||||
Text="{Binding NewInstanceName}" />
|
||||
<Button Content="Anlegen" Command="{Binding CreateCommand}" />
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,14">
|
||||
<TextBlock Classes="h1" Text="{Binding Title}" />
|
||||
<TextBlock Classes="muted" FontSize="13" Text="{Binding Hint}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="caption" TextWrapping="Wrap"
|
||||
Text="{Binding InstancesDirectory, StringFormat='Verzeichnis: {0}'}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" Content="Abbrechen" Margin="0,0,6,0"
|
||||
Command="{Binding CancelCommand}" />
|
||||
<!-- Fehlerzeile: nimmt keinen Platz weg, solange nichts anliegt -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource DangerBrush}"
|
||||
Text="{Binding ErrorText}"
|
||||
IsVisible="{Binding ErrorText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Button Grid.Column="2" Content="Starten"
|
||||
Classes="accent"
|
||||
IsDefault="True"
|
||||
Command="{Binding StartCommand}" />
|
||||
</Grid>
|
||||
<!-- Neue Instanz anlegen -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,14,0,0">
|
||||
<Border Classes="hr" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding NewInstanceName}"
|
||||
PlaceholderText="Name der neuen Instanz" />
|
||||
<Button Grid.Column="1" Classes="primary" Margin="8,0,0,0"
|
||||
Command="{Binding CreateCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
|
||||
<TextBlock Text="Anlegen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Liste -->
|
||||
<DataGrid ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding Selected}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="Single"
|
||||
GridLinesVisibility="Horizontal">
|
||||
<!-- Abschluss -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||
<Button Content="Abbrechen" Command="{Binding CancelCommand}" IsCancel="True" />
|
||||
<Button Classes="primary" Content="{Binding ConfirmLabel}" IsDefault="True"
|
||||
Command="{Binding StartCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Instanzname" Width="2*"
|
||||
Binding="{Binding InstanceName, x:DataType=models:InstanceInfo}" />
|
||||
<DataGridTextColumn Header="Ordner" Width="2*"
|
||||
Binding="{Binding FolderName, x:DataType=models:InstanceInfo}" />
|
||||
<DataGridTextColumn Header="Agenten" Width="Auto"
|
||||
Binding="{Binding AgentCount, x:DataType=models:InstanceInfo}" />
|
||||
<DataGridTextColumn Header="API-Key" Width="Auto"
|
||||
Binding="{Binding ApiKeyStatus, x:DataType=models:InstanceInfo}" />
|
||||
</DataGrid.Columns>
|
||||
<!-- Liste -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="InstanceList" ItemsSource="{Binding Instances}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="6" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
</DataGrid>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:InstanceChoiceViewModel">
|
||||
<Border Classes="card" Padding="8,8">
|
||||
<DockPanel>
|
||||
|
||||
</DockPanel>
|
||||
<!-- Monogramm mit Statuspunkt -->
|
||||
<Panel DockPanel.Dock="Left" Width="30" Height="30" Margin="0,0,10,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="26" Height="26" Background="{DynamicResource Accent600Brush}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top">
|
||||
<TextBlock Classes="h2" FontSize="10" Text="{Binding Monogram}"
|
||||
Foreground="{DynamicResource Neutral100Brush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
|
||||
Fill="{DynamicResource SuccessBrush}"
|
||||
IsVisible="{Binding IsReady}"
|
||||
ToolTip.Tip="API-Key hinterlegt" />
|
||||
<Ellipse Width="10" Height="10" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Stroke="{DynamicResource SurfaceBrush}" StrokeThickness="2"
|
||||
Fill="{DynamicResource Neutral500Brush}"
|
||||
IsVisible="{Binding !IsReady}"
|
||||
ToolTip.Tip="Kein API-Key — Agenten bleiben deaktiviert" />
|
||||
</Panel>
|
||||
|
||||
<Button DockPanel.Dock="Right" Classes="toolbar" VerticalAlignment="Center"
|
||||
Content="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ConfirmLabel}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:InstancePickerViewModel)DataContext).ActivateCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
|
||||
<Border DockPanel.Dock="Right" Classes="tag ok" Margin="0,0,8,0"
|
||||
IsVisible="{Binding IsCurrent}">
|
||||
<TextBlock Text="Aktiv" />
|
||||
</Border>
|
||||
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Classes="h2" FontSize="13.5" Text="{Binding InstanceName}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="caption" Text="{Binding Summary}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</ctrl:BlueprintFrame>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -10,15 +10,21 @@ public partial class InstancePickerWindow : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Doppelklick auf eine Zeile startet — wie in der WinForms-Fassung. Das laesst
|
||||
// sich nicht sinnvoll als Befehl binden, weil DataGrid kein DoubleTapped je
|
||||
// Zeile anbietet; deshalb hier und nicht im Ansichtsmodell.
|
||||
AddHandler(DoubleTappedEvent, OnDoubleTapped, handledEventsToo: false);
|
||||
// Doppelklick auf eine Zeile bestaetigt — wie in der WinForms-Fassung.
|
||||
//
|
||||
// Der Behandler haengt an der Liste und nicht mehr am Fenster: Am Fenster loeste
|
||||
// auch ein Doppelklick ins Namensfeld die Auswahl aus, was seit dem Umbau
|
||||
// deutlich leichter passiert, weil das Feld gross und mittig steht.
|
||||
InstanceList.AddHandler(DoubleTappedEvent, OnRowDoubleTapped, handledEventsToo: false);
|
||||
}
|
||||
|
||||
private void OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||
private void OnRowDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is InstancePickerViewModel vm && vm.StartCommand.CanExecute(null))
|
||||
vm.StartCommand.Execute(null);
|
||||
if (DataContext is not InstancePickerViewModel vm) return;
|
||||
|
||||
// Getroffen wird irgendein Element in der Zeile; der Datenkontext ist derselbe.
|
||||
if ((e.Source as Control)?.DataContext is not InstanceChoiceViewModel choice) return;
|
||||
|
||||
vm.ActivateCommand.Execute(choice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
|
||||
</Border>
|
||||
|
||||
<TextBox Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
<TextBox PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
Text="{Binding Key}"
|
||||
FontFamily="{DynamicResource ContentControlThemeFontFamily}" />
|
||||
|
||||
|
||||
@@ -4,33 +4,50 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:svc="using:ClawdDotNet.App.Services"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
x:Class="ClawdDotNet.Desktop.Views.LogPageView"
|
||||
x:DataType="vm:LogPageViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Werkzeugleiste -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
|
||||
|
||||
<TextBlock Text="Modul:" VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding Modules}"
|
||||
SelectedItem="{Binding SelectedModule}"
|
||||
MinWidth="180" />
|
||||
<TextBlock Text="Modul:" FontSize="12.5" VerticalAlignment="Center" />
|
||||
<ComboBox ItemsSource="{Binding Modules}" SelectedItem="{Binding SelectedModule}"
|
||||
Width="180" MinHeight="30" Padding="8,3" />
|
||||
|
||||
<TextBlock Text="Stufe:" VerticalAlignment="Center" Margin="12,0,0,0" />
|
||||
<ComboBox ItemsSource="{Binding Levels}"
|
||||
SelectedItem="{Binding SelectedLevel}"
|
||||
MinWidth="100" />
|
||||
<TextBlock Text="Stufe:" FontSize="12.5" VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
<ComboBox ItemsSource="{Binding Levels}" SelectedItem="{Binding SelectedLevel}"
|
||||
Width="120" MinHeight="30" Padding="8,3" />
|
||||
|
||||
<Button Content="Aktualisieren" Command="{Binding RefreshModulesCommand}" Margin="12,0,0,0" />
|
||||
<Button Content="Leeren" Command="{Binding ClearCommand}" />
|
||||
<Button Content="Ordner öffnen" Command="{Binding OpenFolderCommand}" />
|
||||
<Border Classes="sep" />
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding RefreshModulesCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding ClearCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
|
||||
<TextBlock Text="Leeren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding OpenFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconFolder}" Width="14" Height="14" />
|
||||
<TextBlock Text="Ordner öffnen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<CheckBox Content="Mitlaufen" IsChecked="{Binding FollowTail}"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0" />
|
||||
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -38,29 +55,41 @@
|
||||
einzelne Zeile herauskopieren, und die Virtualisierung des ItemsControl haelt auch
|
||||
bei 2000 Zeilen die Darstellung fluessig.
|
||||
-->
|
||||
<ScrollViewer Name="Scroller" Padding="8">
|
||||
<ItemsControl ItemsSource="{Binding Lines}">
|
||||
<Border Classes="console">
|
||||
<ScrollViewer Name="Scroller" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Lines}">
|
||||
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svc:LogLine">
|
||||
<SelectableTextBlock Text="{Binding Text}"
|
||||
FontFamily="Consolas, Menlo, DejaVu Sans Mono, monospace"
|
||||
FontSize="12"
|
||||
TextWrapping="NoWrap"
|
||||
Foreground="{Binding Level,
|
||||
Converter={x:Static conv:LogLevelBrushConverter.Instance},
|
||||
TargetNullValue={x:Null}}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="svc:LogLine">
|
||||
<!-- Nur Warnung und Fehler werden hervorgehoben. Alles einzufärben macht
|
||||
die Ansicht unruhig und lenkt von genau den Zeilen ab, die auffallen
|
||||
sollen; der Rest behält die Vordergrundfarbe des Themas. -->
|
||||
<SelectableTextBlock Classes="mono"
|
||||
Classes.warn="{Binding Level,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=WRN}"
|
||||
Classes.err="{Binding Level,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=ERR}"
|
||||
Classes.ftl="{Binding Level,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=FTL}"
|
||||
Text="{Binding Text}"
|
||||
LineHeight="21"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -3,70 +3,224 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="1440" d:DesignHeight="860"
|
||||
x:Class="ClawdDotNet.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/app.ico"
|
||||
Title="ClawdDotNet"
|
||||
Width="1100" Height="700"
|
||||
MinWidth="800" MinHeight="500">
|
||||
Width="1440" Height="860"
|
||||
MinWidth="960" MinHeight="600">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<DockPanel>
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- Haupt-Menüleiste -->
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_Datei">
|
||||
<MenuItem Header="_Beenden" Command="{Binding ExitCommand}" InputGesture="Alt+F4" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Bearbeiten">
|
||||
<MenuItem Header="_Instanz-Ordner öffnen" Command="{Binding OpenInstanceFolderCommand}" />
|
||||
<MenuItem Header="_Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Instanz">
|
||||
<MenuItem Header="_Instanz-Manager..." Command="{Binding OpenInstanceManagerCommand}" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Ansicht">
|
||||
<MenuItem Header="_Chat" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Chat" />
|
||||
<MenuItem Header="_Logs" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Logs" />
|
||||
<MenuItem Header="_Agenten" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Agenten" />
|
||||
<MenuItem Header="_Einstellungen" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Einstellungen" />
|
||||
<MenuItem Header="_Aufgaben" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Aufgaben" />
|
||||
<MenuItem Header="_Sicherung" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Sicherung" />
|
||||
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Hilfe">
|
||||
<MenuItem Header="_Info" Command="{Binding SelectPageByTitleCommand}" CommandParameter="Info" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<!-- ══════════════════════════ Seitenleiste ══════════════════════════ -->
|
||||
<Border Grid.Column="0" Classes="sidebar" Width="{Binding SidebarWidth}">
|
||||
<DockPanel>
|
||||
|
||||
<!-- Statusleiste -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Classes="caption" Text="{Binding StatusText}" />
|
||||
<!-- Marke und Einklapp-Schalter -->
|
||||
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="14">
|
||||
<DockPanel>
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="20" Height="20"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
DockPanel.Dock="Left" VerticalAlignment="Center" />
|
||||
|
||||
<Button DockPanel.Dock="Right" Classes="flat"
|
||||
Command="{Binding ToggleSidebarCommand}"
|
||||
ToolTip.Tip="Seitenleiste ein-/ausklappen"
|
||||
Padding="4" VerticalAlignment="Center">
|
||||
<ctrl:StrokeIcon Width="14" Height="14"
|
||||
Data="{Binding IsSidebarCollapsed,
|
||||
Converter={x:Static conv:SidebarChevronConverter.Instance}}" />
|
||||
</Button>
|
||||
|
||||
<TextBlock Classes="h2" Text="ClawdDotNet" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsSidebarExpanded}" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Fußzeile: Version -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="16,10"
|
||||
Text="{Binding VersionText}"
|
||||
IsVisible="{Binding IsSidebarExpanded}" />
|
||||
|
||||
<!-- Instanz-Schalter: der einzige Knopf mit Akzentrahmen, weil er als
|
||||
einziger die ganze Anwendung umschaltet. -->
|
||||
<Button DockPanel.Dock="Top" Classes="instance" Margin="12,12,12,0"
|
||||
Command="{Binding OpenInstanceManagerCommand}"
|
||||
ToolTip.Tip="Instanz wechseln">
|
||||
<DockPanel>
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
|
||||
DockPanel.Dock="Left" VerticalAlignment="Center" />
|
||||
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconChevronDown}" Width="14" Height="14"
|
||||
DockPanel.Dock="Right" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsSidebarExpanded}" />
|
||||
|
||||
<StackPanel Margin="8,0,0,0" IsVisible="{Binding IsSidebarExpanded}">
|
||||
<TextBlock Text="INSTANZ" FontSize="9" LetterSpacing="1"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
<TextBlock Classes="h2" FontSize="13" Text="{Binding InstanceName}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="10,14">
|
||||
<ItemsControl ItemsSource="{Binding NavGroups}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="18" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:NavGroupViewModel">
|
||||
<StackPanel Spacing="2">
|
||||
|
||||
<TextBlock Classes="navgroup" Text="{Binding Title}"
|
||||
IsVisible="{Binding IsTitleVisible}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="2" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:NavItemViewModel">
|
||||
<Button Classes="nav"
|
||||
Classes.selected="{Binding IsSelected}"
|
||||
Classes.collapsed="{Binding IsCollapsed}"
|
||||
Command="{Binding SelectCommand}"
|
||||
ToolTip.Tip="{Binding Title}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<ctrl:StrokeIcon Width="17" Height="17"
|
||||
VerticalAlignment="Center"
|
||||
Data="{Binding IconKey,
|
||||
Converter={x:Static conv:IconConverter.Instance}}" />
|
||||
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !IsCollapsed}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Bereiche (Seitenleiste links) -->
|
||||
<TabControl ItemsSource="{Binding Pages}"
|
||||
SelectedItem="{Binding SelectedPage}"
|
||||
TabStripPlacement="Left">
|
||||
<!-- ══════════════════════════ Hauptbereich ══════════════════════════ -->
|
||||
<DockPanel Grid.Column="1">
|
||||
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:PageViewModel">
|
||||
<TextBlock Text="{Binding Title}" Margin="4,8" />
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
<!-- Kopfzeile -->
|
||||
<Border DockPanel.Dock="Top" Classes="topbar">
|
||||
<DockPanel>
|
||||
<TextBlock Classes="h1" Text="{Binding PageTitle}" VerticalAlignment="Center" />
|
||||
|
||||
<TabControl.ContentTemplate>
|
||||
<DataTemplate DataType="vm:PageViewModel">
|
||||
<ContentControl Content="{Binding}" />
|
||||
</DataTemplate>
|
||||
</TabControl.ContentTemplate>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="10"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Classes="caption" Text="{Binding ThemeLabel}" VerticalAlignment="Center" />
|
||||
<Button Classes="icon" Command="{Binding ToggleThemeCommand}"
|
||||
ToolTip.Tip="Dunkelmodus umschalten">
|
||||
<ctrl:StrokeIcon Width="16" Height="16"
|
||||
Data="{Binding ThemeIconKey,
|
||||
Converter={x:Static conv:IconConverter.Instance}}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</TabControl>
|
||||
<!-- Benachrichtigungsleiste: nimmt keinen Platz, solange nichts anliegt -->
|
||||
<ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding Notifications}"
|
||||
IsVisible="{Binding HasNotifications}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:NotificationViewModel">
|
||||
<Panel>
|
||||
<!-- Zwei Grundflächen statt eines umgerechneten Pinsels: So bleiben die
|
||||
Farben an DynamicResource gebunden und wechseln mit dem Thema mit. -->
|
||||
<Border Background="{DynamicResource DangerBarBrush}"
|
||||
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="3,0,0,0"
|
||||
IsVisible="{Binding IsError}" />
|
||||
<Border Background="{DynamicResource WarningSoftBrush}"
|
||||
BorderBrush="{DynamicResource WarningBrush}" BorderThickness="3,0,0,0"
|
||||
IsVisible="{Binding IsWarning}" />
|
||||
|
||||
</DockPanel>
|
||||
<DockPanel Margin="24,8">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconAlert}" Width="15" Height="15"
|
||||
DockPanel.Dock="Left" VerticalAlignment="Center"
|
||||
Margin="0,0,10,0" />
|
||||
|
||||
<Button DockPanel.Dock="Right" Classes="flat" Padding="2"
|
||||
Command="{Binding DismissCommand}" ToolTip.Tip="Ausblenden"
|
||||
VerticalAlignment="Center">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconClose}" Width="13" Height="13" />
|
||||
</Button>
|
||||
|
||||
<TextBlock FontSize="12.5" VerticalAlignment="Center" TextWrapping="Wrap">
|
||||
<Run Text="{Binding InstanceName}" FontWeight="SemiBold" /><Run Text=": " /><Run Text="{Binding Text}" />
|
||||
</TextBlock>
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Statusleiste -->
|
||||
<Border DockPanel.Dock="Bottom" Classes="statusbar">
|
||||
<DockPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="caption" Text="{Binding InstanceName, StringFormat='Instanz: {0}'}" />
|
||||
<TextBlock Classes="caption" Text="·" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource SuccessBrush}"
|
||||
IsVisible="{Binding IsApiReachable}" />
|
||||
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource DangerBrush}"
|
||||
IsVisible="{Binding !IsApiReachable}" />
|
||||
<TextBlock Classes="caption" Text="{Binding ApiStatusText, StringFormat='API {0}'}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="caption" Text="·"
|
||||
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="caption" Text="{Binding UsageText}"
|
||||
ToolTip.Tip="{Binding UsageTooltip}"
|
||||
IsVisible="{Binding UsageText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- Der Trenner gehoert zum Abschnitt dahinter: Ohne Guthabenangabe stuende
|
||||
sonst ein Punkt am Ende der Zeile, der nichts mehr trennt. -->
|
||||
<TextBlock Classes="caption" Text="·"
|
||||
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<TextBlock Classes="caption" Text="{Binding CreditsText}"
|
||||
IsVisible="{Binding CreditsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Right" Classes="caption" HorizontalAlignment="Right"
|
||||
Text="{Binding ThemeLabel}" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<ContentControl Content="{Binding SelectedPage}" Margin="20" />
|
||||
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -13,16 +13,12 @@ public partial class MainWindow : Window
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
vm.RequestExit += Close;
|
||||
vm.RequestInstanceManagerDialog += OnOpenInstanceManager;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnOpenInstanceManager()
|
||||
{
|
||||
var dialog = new InstancePickerWindow();
|
||||
await dialog.ShowDialog(this);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,94 +3,128 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
Padding="16"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="740"
|
||||
x:Class="ClawdDotNet.Desktop.Views.SettingsPageView"
|
||||
x:DataType="vm:SettingsPageViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Statusbar am unteren Rand -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Verwerfen / Zurücksetzen" Command="{Binding LoadFromHostCommand}" />
|
||||
<Button Content="Speichern" Command="{Binding SaveSettingsCommand}" Classes="accent" />
|
||||
<!-- Aktionen unten: Der Entwurf setzt flache Felder ohne Karten, damit bleibt am
|
||||
unteren Rand nur die Zeile mit Verwerfen und Speichern. -->
|
||||
<Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,1,0,0" Padding="0,12,0,0" Margin="0,16,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Content="Verwerfen" Command="{Binding LoadFromHostCommand}" />
|
||||
<Button Classes="primary" Command="{Binding SaveSettingsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconSave}" Width="14" Height="14" />
|
||||
<TextBlock Text="Speichern" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="26" MaxWidth="680" HorizontalAlignment="Left">
|
||||
|
||||
<TextBlock Text="Anwendungs- & Instanz-Einstellungen" Classes="heading" FontSize="20" />
|
||||
<!-- ══════════════════ Instanz ══════════════════ -->
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="kicker" Text="INSTANZ" />
|
||||
|
||||
<!-- Card: Instanz-Einstellungen -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Aktuelle Instanz" Classes="heading" />
|
||||
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Instanz-Name:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding InstanceName}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="OpenRouter API-Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding OpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Tagesbudget ($ USD):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding InstanceDailyCostUsd}" Minimum="0" Maximum="10000" FormatString="F2" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tagesbudget (Tokens):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding InstanceDailyTokens}" Minimum="0" Maximum="100000000" FormatString="0" Width="180" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Instanz-Name" />
|
||||
<TextBox Text="{Binding InstanceName}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Card: Allgemein & Pfade -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Allgemein & Pfade" Classes="heading" />
|
||||
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Log-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding LogDirectory}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Instanzen-Verzeichnis:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding InstancesDirectory}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Minimaler Log-Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="OpenRouter Base-URL:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding OpenRouterBaseUrl}" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="OpenRouter API-Key" />
|
||||
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Card: Deploymentcenter & Lizenz -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Deploymentcenter & Lizenz" Classes="heading" />
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
|
||||
<TextBox Text="{Binding InstanceDailyCostUsd}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Tagesbudget (Tokens)" />
|
||||
<TextBox Text="{Binding InstanceDailyTokens}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Server-URL:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DeploymentcenterUrl}" />
|
||||
<Border Classes="hr" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Master-Token:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DeploymentcenterToken}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
<!-- ══════════════════ Allgemein ══════════════════ -->
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="kicker" Text="ALLGEMEIN" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Update-Kanal:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}" Width="140" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Umgebung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" ItemsSource="{Binding Environments}" SelectedItem="{Binding DeploymentcenterEnvironment}" Width="160" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="Lizenzschlüssel:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding LicenseKey}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<CheckBox Content="Fehler automatisch an Deploymentcenter melden" IsChecked="{Binding ErrorReportingEnabled}" Margin="0,4,0,0" />
|
||||
<CheckBox Content="Beim Start automatisch auf Updates prüfen" IsChecked="{Binding UpdateCheckEnabled}" />
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Log-Verzeichnis" />
|
||||
<TextBox Text="{Binding LogDirectory}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Instanzen-Verzeichnis" />
|
||||
<TextBox Text="{Binding InstancesDirectory}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Minimaler Log-Level" />
|
||||
<ComboBox ItemsSource="{Binding LogLevels}" SelectedItem="{Binding MinimumLogLevel}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="OpenRouter Base-URL" />
|
||||
<TextBox Text="{Binding OpenRouterBaseUrl}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="hr" />
|
||||
|
||||
<!-- ══════════════════ Deploymentcenter ══════════════════ -->
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Classes="kicker" Text="DEPLOYMENTCENTER & LIZENZ" />
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Server-URL" />
|
||||
<TextBox Text="{Binding DeploymentcenterUrl}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Master-Token" />
|
||||
<TextBox Text="{Binding DeploymentcenterToken}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Update-Kanal" />
|
||||
<ComboBox ItemsSource="{Binding UpdateChannels}" SelectedItem="{Binding UpdateChannel}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Umgebung" />
|
||||
<ComboBox ItemsSource="{Binding Environments}"
|
||||
SelectedItem="{Binding DeploymentcenterEnvironment}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Lizenzschlüssel" />
|
||||
<TextBox Text="{Binding LicenseKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="10" Margin="0,4,0,0">
|
||||
<CheckBox Content="Fehler automatisch an Deploymentcenter melden"
|
||||
IsChecked="{Binding ErrorReportingEnabled}" />
|
||||
<CheckBox Content="Beim Start automatisch auf Updates prüfen"
|
||||
IsChecked="{Binding UpdateCheckEnabled}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -3,100 +3,255 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
Padding="16"
|
||||
xmlns:models="using:ClawdDotNet.App.Models"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
|
||||
x:Class="ClawdDotNet.Desktop.Views.TasksPageView"
|
||||
x:DataType="vm:TasksPageViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Statusbar unten -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<!-- Segmentleiste statt Registerkarten. Die Knöpfe überlappen sich um einen
|
||||
Bildpunkt, damit zwischen ihnen nur eine Linie steht und nicht zwei. -->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
|
||||
HorizontalAlignment="Left" Margin="0,0,0,14">
|
||||
<ToggleButton Content="Geplante Jobs" Margin="0,0,-1,0"
|
||||
IsChecked="{Binding IsJobsTab, Mode=OneWay}"
|
||||
Command="{Binding SelectTabCommand}" CommandParameter="Jobs" />
|
||||
<ToggleButton Content="Dienste" Margin="0,0,-1,0"
|
||||
IsChecked="{Binding IsServicesTab, Mode=OneWay}"
|
||||
Command="{Binding SelectTabCommand}" CommandParameter="Services" />
|
||||
<ToggleButton Content="Verlauf"
|
||||
IsChecked="{Binding IsHistoryTab, Mode=OneWay}"
|
||||
Command="{Binding SelectTabCommand}" CommandParameter="History" />
|
||||
</StackPanel>
|
||||
|
||||
<TabControl>
|
||||
<!-- Statuszeile des Bereichs -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Classes="caption" Margin="0,10,0,0"
|
||||
Text="{Binding StatusText}" TextWrapping="Wrap" />
|
||||
|
||||
<!-- TAB 1: Jobs (Scheduled / Worker) -->
|
||||
<TabItem Header="Geplante Jobs">
|
||||
<DockPanel Margin="0,8,0,0">
|
||||
<Panel>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<Button Content="Job hinzufügen" Command="{Binding AddJobCommand}" Classes="accent" />
|
||||
<Button Content="Bearbeiten" Command="{Binding EditJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="Entfernen" Command="{Binding RemoveJobCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="▶ Jetzt ausführen" Command="{Binding RunJobNowCommand}" IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}" Margin="12,0,0,0" />
|
||||
<Button Content="Aktualisieren" Command="{Binding RefreshJobListCommand}" />
|
||||
</StackPanel>
|
||||
<!-- ══════════════════ Geplante Jobs ══════════════════ -->
|
||||
<DockPanel IsVisible="{Binding IsJobsTab}">
|
||||
|
||||
<!-- Jobs DataGrid -->
|
||||
<DataGrid ItemsSource="{Binding JobEntries}" SelectedItem="{Binding SelectedJob}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding JobType}" Width="120" />
|
||||
<DataGridTextColumn Header="Agent" Binding="{Binding AgentName}" Width="150" />
|
||||
<DataGridTextColumn Header="Tool / Nachricht" Binding="{Binding TaskMessage}" Width="*" />
|
||||
<DataGridTextColumn Header="Cron-Ausdruck" Binding="{Binding CronExpression}" Width="120" />
|
||||
<DataGridTextColumn Header="Nächster Lauf" Binding="{Binding NextRun}" Width="140" />
|
||||
<DataGridTextColumn Header="Letzter Lauf" Binding="{Binding LastRun}" Width="140" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
|
||||
<Button Classes="toolbar primary" Command="{Binding AddJobCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
|
||||
<TextBlock Text="Job hinzufügen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<Button Classes="toolbar" Command="{Binding EditJobCommand}"
|
||||
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPencil}" Width="14" Height="14" />
|
||||
<TextBlock Text="Bearbeiten" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- TAB 2: Dienste (Services) -->
|
||||
<TabItem Header="Dienste (Services)">
|
||||
<DockPanel Margin="0,8,0,0">
|
||||
<Button Classes="toolbar" Command="{Binding RemoveJobCommand}"
|
||||
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
|
||||
<TextBlock Text="Entfernen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<Button Content="Dienst hinzufügen" Command="{Binding AddServiceCommand}" Classes="accent" />
|
||||
<Button Content="Entfernen" Command="{Binding RemoveServiceCommand}" IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<Button Content="Aktualisieren" Command="{Binding RefreshServiceListCommand}" />
|
||||
</StackPanel>
|
||||
<Button Classes="toolbar" Command="{Binding ToggleJobEnabledCommand}"
|
||||
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktivieren/Deaktivieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Services DataGrid -->
|
||||
<DataGrid ItemsSource="{Binding ServiceEntries}" SelectedItem="{Binding SelectedService}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Dienstname" Binding="{Binding Name}" Width="180" />
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="100" />
|
||||
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="80" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="110" />
|
||||
<DataGridTextColumn Header="Beschreibung" Binding="{Binding Description}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Border Classes="sep" />
|
||||
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<Button Classes="toolbar" Command="{Binding RunJobNowCommand}"
|
||||
IsEnabled="{Binding SelectedJob, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlay}" Width="12" Height="12" IsFilled="True" />
|
||||
<TextBlock Text="Jetzt ausführen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- TAB 3: Verlauf (Job History) -->
|
||||
<TabItem Header="Verlauf (History)">
|
||||
<DockPanel Margin="0,8,0,0">
|
||||
<Button Classes="toolbar" Command="{Binding RefreshJobListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
|
||||
<Button Content="Verlauf aktualisieren" Command="{Binding RefreshJobHistoryCommand}" />
|
||||
</StackPanel>
|
||||
<Border DockPanel.Dock="Top" Classes="thead">
|
||||
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="TYP" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="AGENT" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="TOOL / NACHRICHT" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="CRON" />
|
||||
<TextBlock Grid.Column="4" Classes="th" Text="NÄCHSTER LAUF" />
|
||||
<TextBlock Grid.Column="5" Classes="th" Text="LETZTER LAUF" />
|
||||
<TextBlock Grid.Column="6" Classes="th" Text="STATUS" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- History DataGrid -->
|
||||
<DataGrid ItemsSource="{Binding JobHistoryEntries}" AutoGenerateColumns="False" IsReadOnly="True" GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Zeitpunkt" Binding="{Binding Time, StringFormat='{}{0:g}'}" Width="140" />
|
||||
<DataGridTextColumn Header="Job-Name" Binding="{Binding JobName}" Width="140" />
|
||||
<DataGridTextColumn Header="Agent" Binding="{Binding Agent}" Width="140" />
|
||||
<DataGridTextColumn Header="Beschreibung" Binding="{Binding JobDescription}" Width="220" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="100" />
|
||||
<DataGridTextColumn Header="Details / Info" Binding="{Binding Info}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<ListBox Classes="table" ItemsSource="{Binding JobEntries}"
|
||||
SelectedItem="{Binding SelectedJob}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:JobDisplayItemViewModel">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="1.1*,1.3*,2.2*,1.1*,1.3*,1.3*,0.9*">
|
||||
<TextBlock Grid.Column="0" Classes="td" Text="{Binding JobType}" />
|
||||
<TextBlock Grid.Column="1" Classes="td" Text="{Binding AgentName}" />
|
||||
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TaskMessage}" />
|
||||
<TextBlock Grid.Column="3" Classes="td mono" Text="{Binding CronExpression}" />
|
||||
<TextBlock Grid.Column="4" Classes="td" Text="{Binding NextRun}" />
|
||||
<TextBlock Grid.Column="5" Classes="td" Text="{Binding LastRun}" />
|
||||
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
<Border Grid.Column="6" Classes="tag"
|
||||
Classes.ok="{Binding Status,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=Aktiv}">
|
||||
<TextBlock Text="{Binding Status}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
</TabControl>
|
||||
<!-- ══════════════════ Dienste ══════════════════ -->
|
||||
<DockPanel IsVisible="{Binding IsServicesTab}">
|
||||
|
||||
<WrapPanel DockPanel.Dock="Top" Classes="toolbar">
|
||||
<Button Classes="toolbar primary" Command="{Binding AddServiceCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPlus}" Width="14" Height="14" />
|
||||
<TextBlock Text="Dienst hinzufügen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding RemoveServiceCommand}"
|
||||
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconTrash}" Width="14" Height="14" />
|
||||
<TextBlock Text="Entfernen" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Classes="sep" />
|
||||
|
||||
<!-- Der Entwurf zeigt hier „Start"/„Stop". Umgelegt wird aber das Kennzeichen
|
||||
in der Konfiguration, nicht ein laufender Prozess — deshalb die ehrlichere
|
||||
Beschriftung. -->
|
||||
<Button Classes="toolbar" Command="{Binding ToggleServiceEnabledCommand}"
|
||||
IsEnabled="{Binding SelectedService, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconPower}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktivieren/Deaktivieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding RefreshServiceListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
|
||||
<Border DockPanel.Dock="Top" Classes="thead">
|
||||
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="DIENSTNAME" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="TYP" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="PORT" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="STATUS" />
|
||||
<TextBlock Grid.Column="4" Classes="th" Text="BESCHREIBUNG" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Classes="table" ItemsSource="{Binding ServiceEntries}"
|
||||
SelectedItem="{Binding SelectedService}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ServiceDisplayItemViewModel">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="1.8*,1*,0.7*,1*,3*">
|
||||
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold" Text="{Binding Name}" />
|
||||
<TextBlock Grid.Column="1" Classes="td" Text="{Binding Type}" />
|
||||
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Port}" />
|
||||
|
||||
<Border Grid.Column="3" Classes="tag"
|
||||
Classes.ok="{Binding Status,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=Bereit}">
|
||||
<TextBlock Text="{Binding Status}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="4" Classes="td dim" Text="{Binding Description}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<!-- ══════════════════ Verlauf ══════════════════ -->
|
||||
<DockPanel IsVisible="{Binding IsHistoryTab}">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Classes="toolbar">
|
||||
<Button Classes="toolbar" Command="{Binding RefreshJobHistoryCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
|
||||
<TextBlock Text="Verlauf aktualisieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Border DockPanel.Dock="Top" Classes="thead">
|
||||
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="ZEITPUNKT" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="JOB" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="AGENT" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="BESCHREIBUNG" />
|
||||
<TextBlock Grid.Column="4" Classes="th" Text="STATUS" />
|
||||
<TextBlock Grid.Column="5" Classes="th" Text="DETAILS" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding JobHistoryEntries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="models:JobHistoryEntry">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="1.3*,1.6*,1.4*,2*,0.9*,2.4*">
|
||||
<TextBlock Grid.Column="0" Classes="td"
|
||||
Text="{Binding Time, StringFormat='{}{0:dd.MM. HH:mm}'}" />
|
||||
<TextBlock Grid.Column="1" Classes="td" Text="{Binding JobName}" />
|
||||
<TextBlock Grid.Column="2" Classes="td" Text="{Binding Agent}" />
|
||||
<TextBlock Grid.Column="3" Classes="td dim" Text="{Binding JobDescription}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="tag"
|
||||
Classes.ok="{Binding Status,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=Success}"
|
||||
Classes.bad="{Binding Status,
|
||||
Converter={x:Static conv:StringEqualsConverter.Instance},
|
||||
ConverterParameter=Error}">
|
||||
<TextBlock Text="{Binding Status}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="5" Classes="td dim" Text="{Binding Info}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DockPanel>
|
||||
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="420"
|
||||
x:Class="ClawdDotNet.Desktop.Views.TextEditorWindow"
|
||||
Icon="/Assets/app.ico"
|
||||
Title="Text bearbeiten"
|
||||
Width="600" Height="420"
|
||||
MinWidth="420" MinHeight="300"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<!-- Der Rahmen mit Eckmarken sitzt hier wie im Entwurf um den ganzen Dialog. Die
|
||||
Marken ragen nach aussen, deshalb 12 Punkte Abstand zum Fensterrand. -->
|
||||
<ctrl:BlueprintFrame Margin="12" Padding="16">
|
||||
<DockPanel>
|
||||
|
||||
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Classes="h1"
|
||||
Text="Text bearbeiten" Margin="0,0,0,12" TextWrapping="Wrap" />
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||
<Button Content="Abbrechen" IsCancel="True" Click="OnCancel" />
|
||||
<Button Classes="primary" Content="Speichern" IsDefault="True" Click="OnSave" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox x:Name="Editor" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalContentAlignment="Top"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||
|
||||
</DockPanel>
|
||||
</ctrl:BlueprintFrame>
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Kleiner Editor für die langen Textfelder eines Agenten (Identity, Soul).
|
||||
///
|
||||
/// <para>Ohne eigenes Ansichtsmodell: Der Dialog hat einen Titel, ein Textfeld und zwei
|
||||
/// Schaltflächen. Ein Ansichtsmodell dafür wäre eine Datei mehr, ohne dass irgendetwas
|
||||
/// prüfbar würde.</para>
|
||||
/// </summary>
|
||||
public partial class TextEditorWindow : Window
|
||||
{
|
||||
private string? _result;
|
||||
|
||||
public TextEditorWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt den Editor und liefert den bearbeiteten Text — oder <c>null</c>, wenn
|
||||
/// abgebrochen wurde. Das Schließen über das Fensterkreuz gilt als Abbruch: Sonst
|
||||
/// wäre ein weggeklickter Dialog dasselbe wie ein bestätigter.
|
||||
/// </summary>
|
||||
public static async Task<string?> ShowAsync(Window owner, string title, string text)
|
||||
{
|
||||
var window = new TextEditorWindow { Title = title };
|
||||
window.TitleText.Text = title;
|
||||
window.Editor.Text = text;
|
||||
|
||||
await window.ShowDialog(owner);
|
||||
return window._result;
|
||||
}
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_result = Editor.Text ?? "";
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_result = null;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:ctrl="using:ClawdDotNet.Desktop.Controls"
|
||||
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||
x:Class="ClawdDotNet.Desktop.Views.TokenUsagePageView"
|
||||
x:DataType="vm:TokenUsagePageViewModel">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="20">
|
||||
|
||||
<!-- Kennzahlen -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Border Classes="card" MinWidth="160">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="kicker" Text="TOKENS (7 TAGE)" />
|
||||
<TextBlock Classes="h2" Text="{Binding TotalTokens}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" MinWidth="160">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="kicker" Text="KOSTEN (7 TAGE)" />
|
||||
<TextBlock Classes="h2" Text="{Binding TotalCost}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card" MinWidth="160">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="kicker" Text="RUNS (7 TAGE)" />
|
||||
<TextBlock Classes="h2" Text="{Binding TotalRuns}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button Classes="toolbar" Command="{Binding RefreshCommand}" VerticalAlignment="Bottom">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ctrl:StrokeIcon Data="{StaticResource IconRefresh}" Width="14" Height="14" />
|
||||
<TextBlock Text="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Hinweis, solange nichts verzeichnet ist -->
|
||||
<TextBlock Classes="caption" Text="{Binding StatusText}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!-- Tagesbalken -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding !IsEmpty}">
|
||||
<TextBlock Classes="h2" Text="Tokens pro Tag" />
|
||||
|
||||
<Border BorderBrush="{DynamicResource DividerBrush}" BorderThickness="0,0,0,1"
|
||||
Padding="0,0,0,2">
|
||||
<ItemsControl ItemsSource="{Binding Days7}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Rows="1" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:TokenDayItem">
|
||||
<StackPanel Spacing="6" VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Center" Margin="8,0">
|
||||
<TextBlock Classes="caption" Text="{Binding TokensLabel}"
|
||||
HorizontalAlignment="Center" />
|
||||
<Rectangle Width="28" Height="{Binding BarHeight}"
|
||||
Fill="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Bottom" />
|
||||
<TextBlock FontSize="11.5" Text="{Binding DayLabel}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Aufschlüsselung -->
|
||||
<StackPanel Spacing="10" MaxWidth="860" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !IsEmpty}">
|
||||
<TextBlock Classes="h2" Text="Aufschlüsselung nach Agent & Modell" />
|
||||
|
||||
<Border Classes="thead">
|
||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
|
||||
<TextBlock Grid.Column="0" Classes="th" Text="AGENT" />
|
||||
<TextBlock Grid.Column="1" Classes="th" Text="MODELL" />
|
||||
<TextBlock Grid.Column="2" Classes="th" Text="TOKENS" />
|
||||
<TextBlock Grid.Column="3" Classes="th" Text="KOSTEN" />
|
||||
<TextBlock Grid.Column="4" Classes="th" Text="RUNS" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ByAgent}" Margin="0,-10,0,0">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:TokenAgentItem">
|
||||
<Border Classes="tr" Padding="7">
|
||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*,0.8*">
|
||||
<TextBlock Grid.Column="0" Classes="td" FontWeight="SemiBold"
|
||||
Text="{Binding AgentName}" />
|
||||
<TextBlock Grid.Column="1" Classes="td dim" Text="{Binding Model}" />
|
||||
<TextBlock Grid.Column="2" Classes="td" Text="{Binding TokensLabel}" />
|
||||
<TextBlock Grid.Column="3" Classes="td" Text="{Binding CostLabel}" />
|
||||
<TextBlock Grid.Column="4" Classes="td" Text="{Binding Runs}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Views;
|
||||
|
||||
public partial class TokenUsagePageView : UserControl
|
||||
{
|
||||
public TokenUsagePageView() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="420"
|
||||
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsView"
|
||||
x:DataType="vm:ToolSettingsViewModel">
|
||||
|
||||
<!--
|
||||
Die Felder eines Werkzeugs — im Entwurf unten rechts auf der Agenten-Seite, nicht
|
||||
mehr in einem eigenen Fenster. Deshalb ein UserControl und kein Window: Der Inhalt
|
||||
ist derselbe, der Rahmen drumherum entscheidet der Aufrufer.
|
||||
|
||||
Flach statt in Karten und ohne verschachtelte Registerkarten (siehe
|
||||
Implementierungsleitfaden im Mockup-Verzeichnis): Beschriftung ueber dem Feld,
|
||||
hoechstens zwei Spalten.
|
||||
-->
|
||||
<StackPanel Spacing="12" MaxWidth="520" HorizontalAlignment="Left">
|
||||
|
||||
<!-- 1. FTP -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Host" />
|
||||
<TextBox Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Port" />
|
||||
<TextBox Text="{Binding FtpPort}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Benutzername" />
|
||||
<TextBox Text="{Binding FtpUsername}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Passwort" />
|
||||
<TextBox Text="{Binding FtpPassword}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Root-Pfad" />
|
||||
<TextBox Text="{Binding FtpRootPath}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 2. Mail -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Benutzername" />
|
||||
<TextBox Text="{Binding MailUsername}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Passwort" />
|
||||
<TextBox Text="{Binding MailPassword}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="IMAP-Host" />
|
||||
<TextBox Text="{Binding MailImapHost}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="IMAP-Port" />
|
||||
<TextBox Text="{Binding MailImapPort}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="SMTP-Host" />
|
||||
<TextBox Text="{Binding MailSmtpHost}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="SMTP-Port" />
|
||||
<TextBox Text="{Binding MailSmtpPort}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Empfänger" />
|
||||
<TextBox Text="{Binding MailAllowedRecipients}"
|
||||
PlaceholderText="adresse1@domain.de, adresse2@domain.de" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 3. Database -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Typ" />
|
||||
<ComboBox ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Connection-String" />
|
||||
<TextBox Text="{Binding DbConnectionString}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Zugriffsebene" />
|
||||
<ComboBox ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Tabellen" />
|
||||
<TextBox Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 4. Telegram -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Bot-Token" />
|
||||
<TextBox Text="{Binding TelegramBotToken}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Standard Chat-ID" />
|
||||
<TextBox Text="{Binding TelegramDefaultChatId}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Chat-IDs" />
|
||||
<TextBox Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 5. DirectAPI -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Standard-Provider" />
|
||||
<TextBox Text="{Binding DirectApiDefaultProvider}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Cache TTL (Sekunden)" />
|
||||
<TextBox Text="{Binding DirectApiCacheTtlSeconds}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Twelve Data Key" />
|
||||
<TextBox Text="{Binding DirectApiTwelveDataKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Alpha Vantage Key" />
|
||||
<TextBox Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 6. WebFetch -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Domains" />
|
||||
<TextBox Text="{Binding WebFetchAllowedDomains}"
|
||||
PlaceholderText="reuters.com, coindesk.com" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Max. Antwortgröße (KB)" />
|
||||
<TextBox Text="{Binding WebFetchMaxResponseKb}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="User Agent" />
|
||||
<TextBox Text="{Binding WebFetchUserAgent}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 7. SocialMediaManager -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="X (Twitter) API-Key" />
|
||||
<TextBox Text="{Binding SmmXApiKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="X Accounts" />
|
||||
<TextBox Text="{Binding SmmXWatchAccounts}" PlaceholderText="account1, account2" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="Subreddits" />
|
||||
<TextBox Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="stocks, investing" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Reddit Post Limit" />
|
||||
<TextBox Text="{Binding SmmRedditPostLimit}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="OpenRouter Key" />
|
||||
<TextBox Text="{Binding SmmOpenRouterApiKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Classes="label" Text="STT-Modell" />
|
||||
<TextBox Text="{Binding SmmSttModel}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="YouTube-Kanäle" />
|
||||
<TextBox Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 8. FileRW -->
|
||||
<StackPanel Spacing="12" IsVisible="{Binding ToolName,
|
||||
Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Dateiendungen (persönlich)" />
|
||||
<TextBox Text="{Binding FileRwPersonalAllowedExtensions}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Zugriffsebene (geteilt)" />
|
||||
<ComboBox ItemsSource="{Binding FileRwAccessLevels}"
|
||||
SelectedItem="{Binding FileRwSharedAccessLevel}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Erlaubte Dateiendungen (geteilt)" />
|
||||
<TextBox Text="{Binding FileRwSharedAllowedExtensions}" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Geschützte Pfade" />
|
||||
<TextBox Text="{Binding FileRwProtectedPaths}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Werkzeuge ohne weitere Einstellungen -->
|
||||
<TextBlock Classes="muted" FontStyle="Italic" TextWrapping="Wrap"
|
||||
Text="Dieses Werkzeug benötigt keine weiteren Einstellungen."
|
||||
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Views;
|
||||
|
||||
public partial class ToolSettingsView : UserControl
|
||||
{
|
||||
public ToolSettingsView() => InitializeComponent();
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
|
||||
xmlns:models="using:ClawdDotNet.Models"
|
||||
xmlns:conv="using:ClawdDotNet.Desktop.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="560" d:DesignHeight="480"
|
||||
Width="580" Height="500"
|
||||
Padding="16"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
Title="Werkzeug-Einstellungen"
|
||||
x:Class="ClawdDotNet.Desktop.Views.ToolSettingsWindow"
|
||||
x:DataType="vm:ToolSettingsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<!-- Bottom Buttons -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
|
||||
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
|
||||
<Button Content="Speichern" Command="{Binding ConfirmCommand}" Classes="accent" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Text="{Binding ToolName, StringFormat='Einstellungen für {0}'}" Classes="heading" FontSize="18" />
|
||||
|
||||
<!-- Forms for each Tool -->
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<!-- 1. FTP -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FTP}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Host:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FtpHost}" PlaceholderText="ftp.example.com" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding FtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FtpUsername}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FtpPassword}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="Root-Pfad:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding FtpRootPath}" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 2. Mail -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Mail}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Benutzername:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding MailUsername}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Passwort:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding MailPassword}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="IMAP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding MailImapHost}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="IMAP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding MailImapPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="SMTP-Host:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding MailSmtpHost}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="SMTP-Port:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding MailSmtpPort}" Minimum="1" Maximum="65535" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.Column="0" Text="Erlaubte Empfänger:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding MailAllowedRecipients}" PlaceholderText="email1@domain.de, email2@domain.de" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 3. Database -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Database}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Typ:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding DbTypes}" SelectedItem="{Binding DbType}" HorizontalAlignment="Stretch" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection-String:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DbConnectionString}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Zugriffsebene:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding DbAccessLevels}" SelectedItem="{Binding DbAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Erlaubte Tabellen:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DbAllowedTables}" PlaceholderText="tabelle1, tabelle2" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 4. Telegram -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=Telegram}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Bot-Token:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding TelegramBotToken}" PasswordChar="*" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Standard Chat-ID:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding TelegramDefaultChatId}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Erlaubte Chat-IDs:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding TelegramAllowedChatIds}" PlaceholderText="id1, id2" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 5. DirectAPI -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=DirectAPI}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Standard-Provider:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding DirectApiDefaultProvider}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Cache TTL (Sek.):" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding DirectApiCacheTtlSeconds}" Minimum="0" Maximum="86400" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Twelve Data Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding DirectApiTwelveDataKey}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Alpha Vantage Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding DirectApiAlphaVantageKey}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 6. WebFetch -->
|
||||
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=WebFetch}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Erlaubte Domains:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding WebFetchAllowedDomains}" PlaceholderText="reuters.com, bloomberg.com" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Max Response KB:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding WebFetchMaxResponseKb}" Minimum="1" Maximum="10000" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="User Agent:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding WebFetchUserAgent}" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 7. SocialMediaManager -->
|
||||
<Grid ColumnDefinitions="160,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=SocialMediaManager}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="X (Twitter) API-Key:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding SmmXApiKey}" PasswordChar="*" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="X Accounts:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding SmmXWatchAccounts}" PlaceholderText="elonmusk, unusual_whales" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Subreddits:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SmmRedditWatchSubreddits}" PlaceholderText="wallstreetbets, stocks" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Reddit Post Limit:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding SmmRedditPostLimit}" Minimum="1" Maximum="100" Width="120" HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" Text="OpenRouter Key:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding SmmOpenRouterApiKey}" PasswordChar="*" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="STT Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding SmmSttModel}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.Column="0" Text="YouTube Kanäle:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding SmmYoutubeChannels}" PlaceholderText="URL1, URL2" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- 8. FileRW -->
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto" IsVisible="{Binding ToolName, Converter={x:Static conv:ToolMatchConverter.Instance}, ConverterParameter=FileRW}">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Personal Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FileRwPersonalAllowedExtensions}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Shared Access Level:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding FileRwAccessLevels}" SelectedItem="{Binding FileRwSharedAccessLevel}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Shared Allowed Ext:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FileRwSharedAllowedExtensions}" Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Geschützte Pfade:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FileRwProtectedPaths}" Margin="0,8,0,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- Fallback info for tools without extra config -->
|
||||
<TextBlock Text="Dieses Werkzeug benötigt keine weiteren Einstellungen."
|
||||
IsVisible="{Binding SettingsObject, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
FontStyle="Italic" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
@@ -1,21 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Views;
|
||||
|
||||
public partial class ToolSettingsWindow : Window
|
||||
{
|
||||
public ToolSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ToolSettingsViewModel vm)
|
||||
{
|
||||
vm.CloseRequested += () => Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"_comment": "Wird vom update-agent bei 'install' und 'configure' gelesen und liegt im Paket neben der manifest.json. Beschreibung des Formats: Deploymentcenter/docs/SETUP_INTEGRATION_GUIDE.md",
|
||||
|
||||
"schema": 1,
|
||||
"displayName": "ClawdDotNet",
|
||||
"description": "ClawdDotNet braucht die Adresse des Deploymentcenters, einen Lizenzschluessel und ein Token fuer Heartbeat und Fehlermeldungen. Das Token stellt der Server selbst aus.",
|
||||
|
||||
"_targets_comment": "location 'config' loest zu %APPDATA% bzw. $XDG_CONFIG_HOME auf - genau das, was AppPaths.ConfigDirectory erwartet. AppPaths schreibt den Ordner plattformabhaengig unterschiedlich: 'ClawdDotNet' unter Windows, 'clawddotnet' unter Linux. Beide Namen sind deshalb ausdruecklich angegeben, statt sich darauf zu verlassen, dass NTFS die Schreibweise ignoriert - auf einem per fsutil umgestellten Ordner oder unter APFS mit Beachtung der Schreibweise traegt das naemlich nicht mehr.",
|
||||
"targets": [
|
||||
{
|
||||
"id": "app",
|
||||
"file": "clawddotnet/Settings.json",
|
||||
"fileWindows": "ClawdDotNet/Settings.json",
|
||||
"fileLinux": "clawddotnet/Settings.json",
|
||||
"location": "config",
|
||||
"format": "json"
|
||||
}
|
||||
],
|
||||
|
||||
"fields": [
|
||||
{
|
||||
"key": "deploymentcenterUrl",
|
||||
"label": "Deploymentcenter",
|
||||
"help": "Adresse, von der auch dieses Paket geladen wurde.",
|
||||
"type": "url",
|
||||
"source": "detect:baseurl"
|
||||
},
|
||||
{
|
||||
"key": "licenseKey",
|
||||
"label": "Lizenzschluessel",
|
||||
"help": "Fuenf Bloecke zu fuenf Zeichen. Oeffnet zugleich die Release-Ablage fuer kuenftige Updates.",
|
||||
"type": "secret",
|
||||
"required": true,
|
||||
"validate": "^[A-Za-z0-9]{5}(-[A-Za-z0-9]{5}){4}$",
|
||||
"validationMessage": "Erwartet werden fuenf Bloecke zu fuenf Zeichen, durch Bindestriche getrennt."
|
||||
},
|
||||
{
|
||||
"key": "deploymentcenterToken",
|
||||
"label": "Token dieser Installation",
|
||||
"help": "Wird vom Server ausgestellt und eingetragen - nichts zu kopieren.",
|
||||
"source": "provision",
|
||||
"scopes": ["watchdog:ping", "bugtracker:report"]
|
||||
},
|
||||
{
|
||||
"key": "updateChannel",
|
||||
"label": "Update-Kanal",
|
||||
"type": "enum",
|
||||
"options": ["prod", "beta", "dev"],
|
||||
"default": "prod",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "deploymentcenterEnvironment",
|
||||
"label": "Umgebung",
|
||||
"help": "Haengt an Fehler- und Bugtracker-Meldungen.",
|
||||
"type": "enum",
|
||||
"options": ["production", "development"],
|
||||
"default": "production",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.RocketChat</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,490 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>Ein Raum, wie ihn das Abonnement des Agenten beschreibt.</summary>
|
||||
internal sealed record RoomSubscription(
|
||||
string RoomId,
|
||||
string Name,
|
||||
string Type, // c = Kanal, p = private Gruppe, d = Direktnachricht
|
||||
int Unread,
|
||||
int UserMentions,
|
||||
int GroupMentions,
|
||||
bool Alert)
|
||||
{
|
||||
public bool IsDirect => Type == "d";
|
||||
|
||||
/// <summary>Menschenlesbare Bezeichnung für Ausgaben.</summary>
|
||||
public string Label => IsDirect ? $"@{Name}" : $"#{Name}";
|
||||
}
|
||||
|
||||
/// <summary>Eine angehängte Datei. <paramref name="FileId"/> ist der Griff zum Abholen.</summary>
|
||||
internal sealed record MessageFile(string FileId, string Name, string ContentType, long Size)
|
||||
{
|
||||
public string Describe()
|
||||
{
|
||||
var size = Size >= 1024 * 1024 ? $"{Size / 1024.0 / 1024.0:0.#} MB"
|
||||
: Size >= 1024 ? $"{Size / 1024.0:0.#} KB"
|
||||
: $"{Size} B";
|
||||
return $"{Name} ({ContentType}, {size}) — fileId: {FileId}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Link samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat.
|
||||
/// Titel und Beschreibung stammen von der verlinkten Seite — also fremder Text, der
|
||||
/// niemals als Anweisung gelesen werden darf.
|
||||
/// </summary>
|
||||
internal sealed record MessageLink(string Url, string? Title, string? Description);
|
||||
|
||||
/// <summary>Eine Nachricht aus der Raum-Historie.</summary>
|
||||
internal sealed record RoomMessage(
|
||||
string Id,
|
||||
DateTime Timestamp,
|
||||
string Username,
|
||||
string UserId,
|
||||
string Text,
|
||||
IReadOnlyList<string> Mentions,
|
||||
string? SystemType,
|
||||
string? ThreadId,
|
||||
IReadOnlyList<MessageFile> Files,
|
||||
IReadOnlyList<MessageLink> Links)
|
||||
{
|
||||
/// <summary>
|
||||
/// Systemnachrichten („Benutzer ist beigetreten", Themenänderung …) tragen ein
|
||||
/// <c>t</c>-Feld. Sie sind kein Gesprächsbeitrag und dürfen keinen Agenten wecken —
|
||||
/// sonst antwortet er auf einen Raumbeitritt.
|
||||
/// </summary>
|
||||
public bool IsSystem => !string.IsNullOrEmpty(SystemType);
|
||||
}
|
||||
|
||||
/// <summary>Fehler der Rocket.Chat-API, mit dem Fehlerschlüssel aus der Antwort.</summary>
|
||||
internal sealed class RocketChatException(string message, string? errorType = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public string? ErrorType { get; } = errorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schmaler REST-Client für Rocket.Chat. Authentifiziert wird je Aufruf über die beiden
|
||||
/// Kopfzeilen <c>X-Auth-Token</c> und <c>X-User-Id</c>; woher das Token stammt
|
||||
/// (Personal Access Token oder vom Administrator erzeugt), ist dem Client gleich.
|
||||
///
|
||||
/// Geprüft gegen Rocket.Chat 8.7.
|
||||
/// </summary>
|
||||
internal sealed class RocketChatClient(RocketChatConfig config)
|
||||
{
|
||||
// Ein geteilter HttpClient — je Aufruf einen neuen zu erzeugen erschöpft unter Last
|
||||
// die Sockets. Die Zugangsdaten hängen deshalb an der Anfrage, nicht am Client.
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<JsonElement> SendAsync(
|
||||
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{config.BaseUrl}{path}");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
JsonElement json;
|
||||
try
|
||||
{
|
||||
json = JsonDocument.Parse(raw).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new RocketChatException(
|
||||
$"Rocket.Chat antwortete mit {(int)response.StatusCode} und keinem JSON: " +
|
||||
Truncate(raw, 200));
|
||||
}
|
||||
|
||||
var success = json.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
|
||||
if (success)
|
||||
return json;
|
||||
|
||||
var errorType = json.TryGetProperty("errorType", out var et) ? et.GetString() : null;
|
||||
var message = json.TryGetProperty("error", out var e) ? e.GetString()
|
||||
: json.TryGetProperty("message", out var m) ? m.GetString()
|
||||
: $"HTTP {(int)response.StatusCode}";
|
||||
|
||||
throw new RocketChatException(message ?? "Unbekannter Fehler", errorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alle Räume des Agenten samt Ungelesen-Zählern — <b>ein</b> Aufruf, unabhängig von
|
||||
/// der Zahl der Räume. Darauf steht der gesamte Poll-Entwurf: Erst dieser Sammelabruf
|
||||
/// entscheidet, ob überhaupt eine Historie geholt werden muss.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RoomSubscription>> GetRoomsAsync(CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(HttpMethod.Get, "/api/v1/subscriptions.get", null, ct);
|
||||
|
||||
if (!json.TryGetProperty("update", out var update) || update.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var rooms = new List<RoomSubscription>();
|
||||
foreach (var item in update.EnumerateArray())
|
||||
{
|
||||
var roomId = GetString(item, "rid");
|
||||
if (string.IsNullOrEmpty(roomId))
|
||||
continue;
|
||||
|
||||
rooms.Add(new RoomSubscription(
|
||||
RoomId: roomId,
|
||||
Name: GetString(item, "name") ?? roomId,
|
||||
Type: GetString(item, "t") ?? "c",
|
||||
Unread: GetInt(item, "unread"),
|
||||
UserMentions: GetInt(item, "userMentions"),
|
||||
GroupMentions: GetInt(item, "groupMentions"),
|
||||
Alert: item.TryGetProperty("alert", out var a) && a.ValueKind == JsonValueKind.True));
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Raumnamen oder eine Raum-Id gegen die Abonnements des Agenten auf.
|
||||
/// Was hier nicht auftaucht, ist ein Raum, in dem der Agent kein Mitglied ist — das
|
||||
/// ist eine klarere Auskunft als der API-Fehler <c>invalid-channel</c>.
|
||||
/// </summary>
|
||||
public static RoomSubscription? Resolve(IReadOnlyList<RoomSubscription> rooms, string roomIdOrName)
|
||||
{
|
||||
var needle = roomIdOrName.TrimStart('#', '@');
|
||||
|
||||
return rooms.FirstOrDefault(r => string.Equals(r.RoomId, roomIdOrName, StringComparison.Ordinal))
|
||||
?? rooms.FirstOrDefault(r => string.Equals(r.Name, needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Nachrichten eines Raums seit einem Zeitpunkt, älteste zuerst.</summary>
|
||||
public async Task<IReadOnlyList<RoomMessage>> GetHistoryAsync(
|
||||
RoomSubscription room, DateTime? since, int count, CancellationToken ct)
|
||||
{
|
||||
// Die Historie hat je Raumart einen eigenen Endpunkt.
|
||||
var endpoint = room.Type switch
|
||||
{
|
||||
"p" => "groups.history",
|
||||
"d" => "im.history",
|
||||
_ => "channels.history"
|
||||
};
|
||||
|
||||
var query = new StringBuilder($"/api/v1/{endpoint}?roomId={Uri.EscapeDataString(room.RoomId)}")
|
||||
.Append("&count=").Append(count);
|
||||
|
||||
if (since is { } from)
|
||||
{
|
||||
query.Append("&oldest=").Append(Uri.EscapeDataString(
|
||||
from.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
query.Append("&inclusive=false");
|
||||
}
|
||||
|
||||
var json = await SendAsync(HttpMethod.Get, query.ToString(), null, ct);
|
||||
|
||||
if (!json.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<RoomMessage>();
|
||||
foreach (var m in messages.EnumerateArray())
|
||||
{
|
||||
var id = GetString(m, "_id");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
|
||||
var user = m.TryGetProperty("u", out var u) ? u : default;
|
||||
|
||||
result.Add(new RoomMessage(
|
||||
Id: id,
|
||||
Timestamp: GetDate(m, "ts"),
|
||||
Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unbekannt",
|
||||
UserId: (user.ValueKind == JsonValueKind.Object ? GetString(user, "_id") : null) ?? "",
|
||||
Text: GetString(m, "msg") ?? "",
|
||||
Mentions: ReadMentions(m),
|
||||
SystemType: GetString(m, "t"),
|
||||
ThreadId: GetString(m, "tmid"),
|
||||
Files: ReadFiles(m),
|
||||
Links: ReadLinks(m)));
|
||||
}
|
||||
|
||||
// Rocket.Chat liefert die jüngste Nachricht zuerst; für einen Verlauf ist die
|
||||
// umgekehrte Reihenfolge die brauchbare.
|
||||
return result.OrderBy(x => x.Timestamp).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet (oder findet) den Direktnachrichten-Raum zu einem Benutzer. Nötig, weil ein
|
||||
/// DM-Raum erst existiert, wenn jemand ihn eröffnet — ohne das könnte ein Agent nur
|
||||
/// auf bestehende Gespräche antworten, aber keines beginnen.
|
||||
/// </summary>
|
||||
public async Task<RoomSubscription?> OpenDirectMessageAsync(string username, CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(HttpMethod.Post, "/api/v1/im.create", new { username }, ct);
|
||||
|
||||
if (!json.TryGetProperty("room", out var room) || room.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var roomId = GetString(room, "_id") ?? GetString(room, "rid");
|
||||
|
||||
return roomId is null
|
||||
? null
|
||||
: new RoomSubscription(roomId, username, "d", 0, 0, 0, false);
|
||||
}
|
||||
|
||||
public async Task<string> PostMessageAsync(
|
||||
string roomId, string text, string? threadId, CancellationToken ct)
|
||||
{
|
||||
object body = threadId is null
|
||||
? new { roomId, text }
|
||||
: new { roomId, text, tmid = threadId };
|
||||
|
||||
var json = await SendAsync(HttpMethod.Post, "/api/v1/chat.postMessage", body, ct);
|
||||
|
||||
return json.TryGetProperty("message", out var m) ? GetString(m, "_id") ?? "" : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt eine Datei in einen Raum. Das läuft in <b>zwei</b> Schritten, anders als der
|
||||
/// Rest der API:
|
||||
///
|
||||
/// 1. <c>rooms.media</c> nimmt die Datei als <c>multipart/form-data</c> entgegen und
|
||||
/// gibt eine Datei-Id zurück — sichtbar ist damit noch nichts.
|
||||
/// 2. <c>rooms.mediaConfirm</c> veröffentlicht sie als Nachricht im Raum.
|
||||
///
|
||||
/// Der frühere Ein-Schritt-Endpunkt <c>rooms.upload</c> existiert in Rocket.Chat 8.7
|
||||
/// nicht mehr (er antwortet mit einem nackten 404). Diese Umsetzung setzt daher eine
|
||||
/// Version voraus, die <c>rooms.media</c> kennt (6.x und neuer).
|
||||
/// </summary>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string roomId, Stream content, string fileName, string contentType,
|
||||
string? message, string? threadId, CancellationToken ct)
|
||||
{
|
||||
var room = Uri.EscapeDataString(roomId);
|
||||
|
||||
// Schritt 1 — die Datei selbst.
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new StreamContent(content);
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
form.Add(file, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"{config.BaseUrl}/api/v1/rooms.media/{room}");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
request.Content = form;
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
JsonElement upload;
|
||||
try
|
||||
{
|
||||
upload = JsonDocument.Parse(raw).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new RocketChatException(
|
||||
$"Upload scheiterte mit {(int)response.StatusCode}: {Truncate(raw, 200)}"
|
||||
+ (response.StatusCode == System.Net.HttpStatusCode.NotFound
|
||||
? " — kennt diese Rocket.Chat-Version den Endpunkt rooms.media?"
|
||||
: ""));
|
||||
}
|
||||
|
||||
if (!upload.TryGetProperty("success", out var ok) || ok.ValueKind != JsonValueKind.True)
|
||||
throw new RocketChatException(
|
||||
(upload.TryGetProperty("error", out var e) ? e.GetString() : null)
|
||||
?? "Upload fehlgeschlagen");
|
||||
|
||||
var fileId = upload.TryGetProperty("file", out var f) ? GetString(f, "_id") : null;
|
||||
if (string.IsNullOrEmpty(fileId))
|
||||
throw new RocketChatException("Upload lieferte keine Datei-Id zurück.");
|
||||
|
||||
// Schritt 2 — ohne ihn liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
|
||||
object confirmBody = threadId is null
|
||||
? new { msg = message ?? "" }
|
||||
: new { msg = message ?? "", tmid = threadId };
|
||||
|
||||
var confirm = await SendAsync(
|
||||
HttpMethod.Post, $"/api/v1/rooms.mediaConfirm/{room}/{Uri.EscapeDataString(fileId)}",
|
||||
confirmBody, ct);
|
||||
|
||||
return confirm.TryGetProperty("message", out var msg) ? GetString(msg, "_id") ?? "" : "";
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(string roomId, CancellationToken ct)
|
||||
=> await SendAsync(HttpMethod.Post, "/api/v1/subscriptions.read", new { rid = roomId }, ct);
|
||||
|
||||
// ─── JSON-Lesehelfer ───
|
||||
|
||||
/// <summary>
|
||||
/// Angehängte Dateien. Rocket.Chat führt sie in <c>files</c>; die Datei-Id daraus ist
|
||||
/// alles, was zum späteren Abholen nötig ist.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MessageFile> ReadFiles(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<MessageFile>();
|
||||
foreach (var f in files.EnumerateArray())
|
||||
{
|
||||
var id = GetString(f, "_id");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
|
||||
result.Add(new MessageFile(
|
||||
FileId: id,
|
||||
Name: GetString(f, "name") ?? id,
|
||||
ContentType: GetString(f, "type") ?? "application/octet-stream",
|
||||
Size: f.TryGetProperty("size", out var s) && s.ValueKind == JsonValueKind.Number
|
||||
&& s.TryGetInt64(out var n) ? n : 0));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat. Das
|
||||
/// erspart dem Agenten oft einen eigenen Abruf der Seite.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MessageLink> ReadLinks(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("urls", out var urls) || urls.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<MessageLink>();
|
||||
foreach (var u in urls.EnumerateArray())
|
||||
{
|
||||
var url = GetString(u, "url");
|
||||
if (string.IsNullOrEmpty(url))
|
||||
continue;
|
||||
|
||||
var meta = u.TryGetProperty("meta", out var m) && m.ValueKind == JsonValueKind.Object
|
||||
? m
|
||||
: default;
|
||||
|
||||
result.Add(new MessageLink(
|
||||
Url: url,
|
||||
Title: meta.ValueKind == JsonValueKind.Object
|
||||
? GetString(meta, "pageTitle") ?? GetString(meta, "ogTitle")
|
||||
: null,
|
||||
Description: meta.ValueKind == JsonValueKind.Object
|
||||
? GetString(meta, "description") ?? GetString(meta, "ogDescription")
|
||||
: null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt eine angehängte Datei. Die Dateiablage liegt nicht unter <c>/api</c>, nimmt
|
||||
/// aber dieselben Kopfzeilen; ohne sie antwortet der Server mit 403. Der Namensteil
|
||||
/// der Adresse ist beliebig, muss aber vorhanden sein.
|
||||
/// </summary>
|
||||
public async Task<long> DownloadToFileAsync(
|
||||
string fileId, string destinationPath, long maxBytes, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"{config.BaseUrl}/file-upload/{Uri.EscapeDataString(fileId)}/download");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
|
||||
using var response = await Http.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new RocketChatException(response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Forbidden =>
|
||||
"Zugriff auf die Datei verweigert — der Agent ist vermutlich kein Mitglied des Raums.",
|
||||
System.Net.HttpStatusCode.NotFound => $"Keine Datei mit der Id {fileId}.",
|
||||
_ => $"Datei konnte nicht geladen werden (HTTP {(int)response.StatusCode})."
|
||||
});
|
||||
|
||||
if (response.Content.Headers.ContentLength is { } declared && declared > maxBytes)
|
||||
throw new RocketChatException(
|
||||
$"Die Datei ist {declared / 1024 / 1024} MB groß und überschreitet die Grenze von "
|
||||
+ $"{maxBytes / 1024 / 1024} MB.");
|
||||
|
||||
await using var source = await response.Content.ReadAsStreamAsync(ct);
|
||||
|
||||
// Erst in eine Nebendatei schreiben: Bricht der Download ab oder reißt die Grenze,
|
||||
// bleibt keine halbe Datei im Workspace liegen, die der Agent für vollständig hält.
|
||||
var temp = destinationPath + ".part";
|
||||
long written = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await using (var target = File.Create(temp))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
written += read;
|
||||
|
||||
// Auch ohne Content-Length darf die Grenze nicht überschritten werden.
|
||||
if (written > maxBytes)
|
||||
throw new RocketChatException(
|
||||
$"Die Datei überschreitet die Grenze von {maxBytes / 1024 / 1024} MB.");
|
||||
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(temp, destinationPath, overwrite: true);
|
||||
return written;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(temp))
|
||||
try { File.Delete(temp); } catch { /* Aufräumen darf den Fehler nicht verdecken */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadMentions(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("mentions", out var mentions)
|
||||
|| mentions.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return mentions.EnumerateArray()
|
||||
.Select(x => GetString(x, "username"))
|
||||
.Where(x => !string.IsNullOrEmpty(x))
|
||||
.Select(x => x!)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name)
|
||||
=> element.ValueKind == JsonValueKind.Object
|
||||
&& element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private static int GetInt(JsonElement element, string name)
|
||||
=> element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.Number
|
||||
&& v.TryGetInt32(out var i)
|
||||
? i
|
||||
: 0;
|
||||
|
||||
private static DateTime GetDate(JsonElement element, string name)
|
||||
=> element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
&& DateTime.TryParse(v.GetString(), null,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal
|
||||
| System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
|
||||
? d
|
||||
: DateTime.UtcNow;
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
/// Die Tool-Konfiguration eines Agenten, aus dem freien Wörterbuch gelesen.
|
||||
///
|
||||
/// Die Werte kommen je nach Weg als <see cref="JsonElement"/> (frisch deserialisiert) oder
|
||||
/// als einfache .NET-Werte (nach einer Bearbeitung in der Oberfläche) an — deshalb liest
|
||||
/// jeder Zugriff über die Helfer unten, nie über eine direkte Umwandlung.
|
||||
/// </summary>
|
||||
internal sealed record RocketChatConfig(
|
||||
string BaseUrl,
|
||||
string UserId,
|
||||
string AuthToken,
|
||||
string? Username,
|
||||
IReadOnlyList<string> AllowedRooms,
|
||||
string? DefaultRoom,
|
||||
bool MentionOnly,
|
||||
IReadOnlyList<string> AgentUsernames,
|
||||
int MaxWakesPerRoomPerHour,
|
||||
int MaxMessagesPerRoom,
|
||||
TimeSpan InitialLookback,
|
||||
long MaxUploadBytes,
|
||||
long MaxDownloadBytes,
|
||||
bool AllowDangerousDownloads)
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest die Konfiguration. Gibt eine sprechende Fehlermeldung zurück, statt mit einer
|
||||
/// Ausnahme zu scheitern — das Modell soll aus dem Ergebnis erkennen, was fehlt.
|
||||
/// </summary>
|
||||
public static (RocketChatConfig? Config, string? Error) Read(
|
||||
IReadOnlyDictionary<string, object?> raw)
|
||||
{
|
||||
var baseUrl = GetString(raw, "baseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
return (null, "Konfigurationsfehler: 'baseUrl' fehlt (z. B. \"https://chat.example.org\").");
|
||||
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
|
||||
return (null, $"Konfigurationsfehler: 'baseUrl' ist keine gültige http(s)-Adresse: {baseUrl}");
|
||||
|
||||
var userId = GetString(raw, "userId");
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return (null, "Konfigurationsfehler: 'userId' fehlt (die Rocket.Chat-Benutzer-Id dieses Agenten).");
|
||||
|
||||
var authToken = GetString(raw, "authToken");
|
||||
if (string.IsNullOrWhiteSpace(authToken))
|
||||
return (null, "Konfigurationsfehler: 'authToken' fehlt (Zugriffstoken des Agenten-Benutzers).");
|
||||
|
||||
return (new RocketChatConfig(
|
||||
BaseUrl: baseUrl,
|
||||
UserId: userId,
|
||||
AuthToken: authToken,
|
||||
Username: GetString(raw, "username"),
|
||||
AllowedRooms: GetStringList(raw, "allowedRooms"),
|
||||
DefaultRoom: GetString(raw, "defaultRoom"),
|
||||
MentionOnly: GetBool(raw, "mentionOnly", defaultValue: true),
|
||||
AgentUsernames: GetStringList(raw, "agentUsernames"),
|
||||
MaxWakesPerRoomPerHour: GetInt(raw, "maxWakesPerRoomPerHour", defaultValue: 12),
|
||||
MaxMessagesPerRoom: Math.Clamp(GetInt(raw, "maxMessagesPerRoom", defaultValue: 20), 1, 100),
|
||||
InitialLookback: TimeSpan.FromMinutes(
|
||||
Math.Clamp(GetInt(raw, "initialLookbackMinutes", defaultValue: 5), 1, 1440)),
|
||||
MaxUploadBytes: Math.Clamp(
|
||||
GetInt(raw, "maxUploadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
|
||||
MaxDownloadBytes: Math.Clamp(
|
||||
GetInt(raw, "maxDownloadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
|
||||
AllowDangerousDownloads: GetBool(raw, "allowDangerousDownloads", defaultValue: false)),
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Raum benutzt werden darf. Eine leere Liste erlaubt jeden Raum — wer
|
||||
/// keine Allowlist pflegt, hat sich dagegen entschieden; das Tool erfindet keine.
|
||||
/// Verglichen wird gegen Raum-Id <b>und</b> Raumname, weil das Modell beides kennt.
|
||||
/// </summary>
|
||||
public bool IsRoomAllowed(string roomIdOrName)
|
||||
{
|
||||
if (AllowedRooms.Count == 0)
|
||||
return true;
|
||||
|
||||
return AllowedRooms.Any(r =>
|
||||
string.Equals(r, roomIdOrName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public bool IsAgentUser(string? username)
|
||||
=> username is not null
|
||||
&& AgentUsernames.Any(a => string.Equals(a, username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ─── Lesehelfer für das freie Wörterbuch ───
|
||||
|
||||
private static string? GetString(IReadOnlyDictionary<string, object?> raw, string key)
|
||||
=> raw.TryGetValue(key, out var v) ? AsString(v) : null;
|
||||
|
||||
private static string? AsString(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => string.IsNullOrWhiteSpace(s) ? null : s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
|
||||
JsonElement je => je.ToString(),
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
private static bool GetBool(IReadOnlyDictionary<string, object?> raw, string key, bool defaultValue)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return defaultValue;
|
||||
|
||||
return v switch
|
||||
{
|
||||
bool b => b,
|
||||
JsonElement { ValueKind: JsonValueKind.True } => true,
|
||||
JsonElement { ValueKind: JsonValueKind.False } => false,
|
||||
_ => bool.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetInt(IReadOnlyDictionary<string, object?> raw, string key, int defaultValue)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return defaultValue;
|
||||
|
||||
return v switch
|
||||
{
|
||||
int i => i,
|
||||
long l => (int)l,
|
||||
JsonElement { ValueKind: JsonValueKind.Number } je when je.TryGetInt32(out var n) => n,
|
||||
_ => int.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetStringList(
|
||||
IReadOnlyDictionary<string, object?> raw, string key)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return [];
|
||||
|
||||
return v switch
|
||||
{
|
||||
JsonElement { ValueKind: JsonValueKind.Array } je =>
|
||||
je.EnumerateArray()
|
||||
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!)
|
||||
.ToList(),
|
||||
IEnumerable<string> list => list.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(),
|
||||
_ => AsString(v) is { } single ? [single] : []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an eine selbstgehostete Rocket.Chat-Instanz. Jeder Agent tritt mit einem
|
||||
/// eigenen Benutzer auf — dadurch funktionieren Direktnachrichten und Erwähnungen echt
|
||||
/// und nicht als Alias eines Sammelkontos.
|
||||
///
|
||||
/// Der Rückkanal läuft über <see cref="IToolJobProvider"/>: Ein Takt prüft ohne Modell
|
||||
/// (und damit kostenlos), ob etwas anliegt, und weckt den Agenten nur dann.
|
||||
/// </summary>
|
||||
public sealed class RocketChatTool : IAgentTool, IToolJobProvider
|
||||
{
|
||||
public string Name => "RocketChat";
|
||||
|
||||
public string Description => """
|
||||
Kommunikation über Rocket.Chat: Nachrichten in Kanälen, privaten Gruppen und
|
||||
Direktnachrichten lesen und schreiben.
|
||||
|
||||
Räume werden per Id ("GENERAL") oder Name ("#team") angegeben; list_rooms zeigt,
|
||||
wo dieser Agent Mitglied ist. Mit "@benutzername" wird eine Direktnachricht
|
||||
geschrieben — auch an jemanden, mit dem noch kein Gespräch besteht.
|
||||
|
||||
Aktionen: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["send_message", "reply", "send_file", "download_file", "list_rooms", "read_room", "mark_read"],
|
||||
"description": "send_message: Nachricht in einen Raum senden. reply: Antwort im Thread einer Nachricht. send_file: eine Datei aus dem Workspace in einen Raum schicken. download_file: eine empfangene Datei in den Workspace holen. list_rooms: eigene Räume mit Ungelesen-Zählern auflisten. read_room: letzte Nachrichten eines Raums lesen. mark_read: Raum als gelesen markieren."
|
||||
},
|
||||
"room": {
|
||||
"type": "string",
|
||||
"description": "Raum-Id oder -Name (z. B. \"GENERAL\", \"#team\", \"@person\"). Entfällt bei list_rooms; ohne Angabe gilt der konfigurierte Standardraum."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Nachrichtentext (für send_message und reply)."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string",
|
||||
"description": "Id der Nachricht, auf die geantwortet wird (nur für reply)."
|
||||
},
|
||||
"localPath": {
|
||||
"type": "string",
|
||||
"description": "Pfad der zu sendenden Datei im Workspace (nur für send_file): \"bericht.pdf\" oder \"personal:bericht.pdf\" im eigenen Workspace, \"shared:auswertung.csv\" im geteilten."
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Abweichender Dateiname für den Empfänger (nur für send_file). Ohne Angabe gilt der Name im Workspace."
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string",
|
||||
"description": "Id einer empfangenen Datei (nur für download_file). Steht in der Nachricht als \"fileId: …\"."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Anzahl der zu lesenden Nachrichten bei read_room (Standard 20, höchstens 100)."
|
||||
}
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
// ─── IAgentTool ───
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (!input.TryGetProperty("action", out var actionElement)
|
||||
|| actionElement.GetString() is not { } action)
|
||||
return ToolResult.Fail(
|
||||
"'action' ist erforderlich. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.");
|
||||
|
||||
var (config, configError) = RocketChatConfig.Read(context.ToolConfig);
|
||||
if (config is null)
|
||||
return ToolResult.Fail(configError!);
|
||||
|
||||
var client = new RocketChatClient(config);
|
||||
context.Logger.LogInformation("RocketChat: Aktion {Action}", action);
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"send_message" => await SendMessageAsync(client, config, input, threadAllowed: false, ct),
|
||||
"reply" => await SendMessageAsync(client, config, input, threadAllowed: true, ct),
|
||||
"send_file" => await SendFileAsync(client, config, input, context, ct),
|
||||
"download_file" => await DownloadFileAsync(client, config, input, context, ct),
|
||||
"list_rooms" => await ListRoomsAsync(client, ct),
|
||||
"read_room" => await ReadRoomAsync(client, config, input, ct),
|
||||
"mark_read" => await MarkReadAsync(client, config, input, ct),
|
||||
_ => ToolResult.Fail(
|
||||
$"Unbekannte Aktion '{action}'. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.")
|
||||
};
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
context.Logger.LogWarning("RocketChat meldet einen Fehler bei {Action}: {Message}", action, ex.Message);
|
||||
return ToolResult.Fail($"Rocket.Chat: {ex.Message}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
context.Logger.LogWarning(ex, "Rocket.Chat ist nicht erreichbar");
|
||||
return ToolResult.Fail($"Rocket.Chat ist nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return ToolResult.Fail("Rocket.Chat hat nicht rechtzeitig geantwortet (Zeitüberschreitung).");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> SendMessageAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
bool threadAllowed, CancellationToken ct)
|
||||
{
|
||||
var text = GetString(input, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("'text' ist erforderlich.");
|
||||
|
||||
var threadId = threadAllowed ? GetString(input, "threadId") : null;
|
||||
if (threadAllowed && string.IsNullOrWhiteSpace(threadId))
|
||||
return ToolResult.Fail("'threadId' ist für reply erforderlich (Id der Nachricht, auf die geantwortet wird).");
|
||||
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var messageId = await client.PostMessageAsync(room.RoomId, text, threadId, ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
sent = true,
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
messageId,
|
||||
inThread = threadId is not null
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schickt eine Datei aus dem Workspace des Agenten in einen Raum — der kurze Weg für
|
||||
/// „stell mir das zusammen und schick es rüber", ohne Umweg über die Cloud.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> SendFileAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var localPath = GetString(input, "localPath");
|
||||
if (string.IsNullOrWhiteSpace(localPath))
|
||||
return ToolResult.Fail(
|
||||
"'localPath' ist erforderlich — z. B. \"bericht.pdf\" (persönlicher Workspace) "
|
||||
+ "oder \"shared:auswertung.csv\" (geteilter Workspace).");
|
||||
|
||||
var (file, pathError) = WorkspaceFile.Resolve(
|
||||
localPath, context.WorkspacePath, context.SharedWorkspacePath);
|
||||
if (file is null)
|
||||
return ToolResult.Fail(pathError!);
|
||||
|
||||
if (!File.Exists(file.FullPath))
|
||||
return ToolResult.Fail($"Datei nicht gefunden: {localPath}");
|
||||
|
||||
var info = new FileInfo(file.FullPath);
|
||||
if (info.Length == 0)
|
||||
return ToolResult.Fail($"Die Datei {localPath} ist leer.");
|
||||
|
||||
if (info.Length > config.MaxUploadBytes)
|
||||
return ToolResult.Fail(
|
||||
$"Die Datei ist {info.Length / 1024 / 1024} MB groß und überschreitet die Grenze von "
|
||||
+ $"{config.MaxUploadBytes / 1024 / 1024} MB. Für Größeres eignet sich die Cloud-Ablage.");
|
||||
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var fileName = GetString(input, "filename") ?? Path.GetFileName(file.FullPath);
|
||||
|
||||
await using var stream = File.OpenRead(file.FullPath);
|
||||
var messageId = await client.UploadFileAsync(
|
||||
room.RoomId, stream, fileName,
|
||||
WorkspaceFile.GuessContentType(fileName),
|
||||
GetString(input, "text"),
|
||||
GetString(input, "threadId"),
|
||||
ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
sent = true,
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
fileName,
|
||||
bytes = info.Length,
|
||||
workspace = file.Label,
|
||||
messageId
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt eine angehängte Datei in den Workspace des Agenten — das Gegenstück zu
|
||||
/// <c>send_file</c>. Die <c>fileId</c> steht in der Weckmeldung bzw. in read_room.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> DownloadFileAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var fileId = GetString(input, "fileId");
|
||||
if (string.IsNullOrWhiteSpace(fileId))
|
||||
return ToolResult.Fail(
|
||||
"'fileId' ist erforderlich — sie steht bei jeder Datei in der Nachricht "
|
||||
+ "(\"Datei: … — fileId: …\").");
|
||||
|
||||
// Zielname: Angabe des Modells, sonst der Name aus der Nachricht. Beides ist
|
||||
// fremdbestimmt, deshalb wird der reine Dateiname genommen und jede Pfadangabe
|
||||
// darin verworfen, bevor überhaupt aufgelöst wird.
|
||||
var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId;
|
||||
|
||||
// Den Workspace-Präfix zuerst abtrennen: Auf Windows ist ':' kein Pfadtrenner,
|
||||
// sondern der Marker für NTFS-Alternativdatenströme. Bliebe "shared:" im Namen,
|
||||
// schriebe Path.GetFileName ihn unverändert durch — und die Datei landete als
|
||||
// Datenstrom am Verzeichnis statt als eigene Datei.
|
||||
var prefix = "";
|
||||
var rest = wanted;
|
||||
if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prefix = "shared:";
|
||||
rest = rest[7..];
|
||||
}
|
||||
else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rest = rest[9..];
|
||||
}
|
||||
|
||||
var targetName = Path.GetFileName(rest.Replace('\\', '/'));
|
||||
if (string.IsNullOrWhiteSpace(targetName))
|
||||
return ToolResult.Fail($"'{wanted}' ergibt keinen gültigen Dateinamen.");
|
||||
|
||||
var extension = Path.GetExtension(targetName);
|
||||
if (!config.AllowDangerousDownloads && DangerousExtensions.Contains(extension))
|
||||
return ToolResult.Fail(
|
||||
$"Dateien vom Typ '{extension}' werden nicht heruntergeladen. "
|
||||
+ "Bei Bedarf 'allowDangerousDownloads' in der Tool-Konfiguration setzen.");
|
||||
|
||||
// Das Verzeichnis darf das Modell wählen (personal:/shared:), den Dateinamen nicht
|
||||
// frei zusammensetzen — deshalb Präfix und bereinigter Name getrennt.
|
||||
var (file, pathError) = WorkspaceFile.Resolve(
|
||||
prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
|
||||
if (file is null)
|
||||
return ToolResult.Fail(pathError!);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);
|
||||
|
||||
var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
downloaded = true,
|
||||
fileId,
|
||||
savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}",
|
||||
workspace = file.Label,
|
||||
bytes
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endungen, die nicht ungefragt im Workspace landen sollen. Die Datei kommt von
|
||||
/// außen — der Dateiname ist eine Behauptung des Absenders, kein Befund.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DangerousExtensions =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".exe", ".dll", ".bat", ".cmd", ".com", ".scr", ".msi", ".ps1",
|
||||
".vbs", ".jar", ".lnk", ".reg", ".sh"
|
||||
};
|
||||
|
||||
private static async Task<ToolResult> ListRoomsAsync(RocketChatClient client, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
source = "rocketchat",
|
||||
data = new
|
||||
{
|
||||
count = rooms.Count,
|
||||
rooms = rooms.Select(r => new
|
||||
{
|
||||
roomId = r.RoomId,
|
||||
name = r.Name,
|
||||
type = r.Type switch { "c" => "channel", "p" => "private", "d" => "direct", _ => r.Type },
|
||||
unread = r.Unread,
|
||||
mentions = r.UserMentions
|
||||
})
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ReadRoomAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 100);
|
||||
var messages = await client.GetHistoryAsync(room, since: null, count: limit, ct);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
source = $"rocketchat_{room.RoomId}",
|
||||
data = new
|
||||
{
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
count = messages.Count(m => !m.IsSystem),
|
||||
messages = messages.Where(m => !m.IsSystem).Select(m => new
|
||||
{
|
||||
messageId = m.Id,
|
||||
at = m.Timestamp,
|
||||
from = m.Username,
|
||||
text = m.Text,
|
||||
threadId = m.ThreadId,
|
||||
files = m.Files.Count == 0 ? null : m.Files.Select(f => new
|
||||
{
|
||||
fileId = f.FileId,
|
||||
name = f.Name,
|
||||
contentType = f.ContentType,
|
||||
size = f.Size
|
||||
}),
|
||||
links = m.Links.Count == 0 ? null : m.Links.Select(l => new
|
||||
{
|
||||
url = l.Url,
|
||||
title = l.Title,
|
||||
description = l.Description is null ? null : Shorten(l.Description, 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Fremder Text — als Daten gerahmt, nicht als Anweisung (K2).
|
||||
return ToolResult.Ok(Untrusted(payload));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> MarkReadAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
await client.MarkReadAsync(room.RoomId, ct);
|
||||
return ToolResult.Ok($"Raum {room.Label} als gelesen markiert.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bestimmt den Zielraum aus Eingabe oder Standardwert und prüft die Allowlist.
|
||||
/// Schlägt die Auflösung fehl, nennt die Meldung die tatsächlich verfügbaren Räume —
|
||||
/// das Modell soll sich korrigieren können, statt zu raten.
|
||||
/// </summary>
|
||||
private static async Task<(RoomSubscription? Room, string? Error)> ResolveRoomAsync(
|
||||
RocketChatClient client, IReadOnlyList<RoomSubscription> rooms,
|
||||
RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var wanted = GetString(input, "room") ?? config.DefaultRoom;
|
||||
if (string.IsNullOrWhiteSpace(wanted))
|
||||
return (null, "'room' ist erforderlich (kein Standardraum konfiguriert). "
|
||||
+ Available(rooms));
|
||||
|
||||
var room = RocketChatClient.Resolve(rooms, wanted);
|
||||
|
||||
// Noch kein Gespräch mit dieser Person: Ein DM-Raum entsteht erst, wenn ihn jemand
|
||||
// eröffnet. Nur bei ausdrücklicher @-Schreibweise — sonst würde ein vertippter
|
||||
// Kanalname stillschweigend zu einer Direktnachricht.
|
||||
if (room is null && wanted.StartsWith('@'))
|
||||
{
|
||||
var username = wanted[1..];
|
||||
if (!config.IsRoomAllowed(username))
|
||||
return (null, $"Direktnachrichten an '{username}' sind für diesen Agenten nicht "
|
||||
+ "freigegeben (siehe 'allowedRooms' in der Tool-Konfiguration).");
|
||||
|
||||
try
|
||||
{
|
||||
room = await client.OpenDirectMessageAsync(username, ct);
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
return (null, $"Direktnachricht an '{username}' nicht möglich: {ex.Message}");
|
||||
}
|
||||
|
||||
if (room is null)
|
||||
return (null, $"Direktnachricht an '{username}' konnte nicht geöffnet werden.");
|
||||
|
||||
return (room, null);
|
||||
}
|
||||
|
||||
if (room is null)
|
||||
return (null, $"Kein Raum '{wanted}' — dieser Agent ist dort kein Mitglied. " + Available(rooms)
|
||||
+ " Für eine neue Direktnachricht '@benutzername' angeben.");
|
||||
|
||||
// Gegen beide Schreibweisen prüfen: Die Allowlist darf Ids oder Namen enthalten.
|
||||
if (!config.IsRoomAllowed(room.RoomId) && !config.IsRoomAllowed(room.Name))
|
||||
return (null, $"Zugriff auf Raum '{wanted}' ist für diesen Agenten nicht freigegeben "
|
||||
+ "(siehe 'allowedRooms' in der Tool-Konfiguration).");
|
||||
|
||||
return (room, null);
|
||||
}
|
||||
|
||||
private static string Available(IReadOnlyList<RoomSubscription> rooms)
|
||||
=> rooms.Count == 0
|
||||
? "Der Agent ist in keinem Raum Mitglied."
|
||||
: "Verfügbar: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})"));
|
||||
|
||||
// ─── IToolJobProvider ───
|
||||
|
||||
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
|
||||
[
|
||||
new("rocketchat_poll", "Rocket.Chat Abruf",
|
||||
"Prüft auf neue Erwähnungen und Direktnachrichten und weckt den Agenten nur, wenn etwas anliegt.")
|
||||
];
|
||||
|
||||
public async Task<ToolJobResult> ExecuteJobAsync(
|
||||
string jobTypeId,
|
||||
IReadOnlyDictionary<string, object?> toolConfig,
|
||||
IStateStore stateStore,
|
||||
ILogger logger,
|
||||
CancellationToken ct,
|
||||
string? agentId = null,
|
||||
string? workspacePath = null)
|
||||
{
|
||||
if (jobTypeId != "rocketchat_poll")
|
||||
return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");
|
||||
|
||||
var (config, configError) = RocketChatConfig.Read(toolConfig);
|
||||
if (config is null)
|
||||
return ToolJobResult.NoAction(configError);
|
||||
|
||||
var client = new RocketChatClient(config);
|
||||
var agent = agentId ?? "default";
|
||||
|
||||
try
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
|
||||
// Vorfilter aus dem Sammelabruf: Nur Räume mit etwas Ungelesenem sind
|
||||
// überhaupt einen zweiten Aufruf wert.
|
||||
var candidates = rooms.Where(r => r.Unread > 0 || r.Alert).ToList();
|
||||
|
||||
logger.LogDebug(
|
||||
"RocketChat: mentionOnly={MentionOnly}; Räume: {Rooms}",
|
||||
config.MentionOnly,
|
||||
string.Join(" | ", rooms.Select(r =>
|
||||
$"{r.Label} t={r.Type} unread={r.Unread} mentions={r.UserMentions}/{r.GroupMentions} alert={r.Alert}")));
|
||||
|
||||
if (config.MentionOnly)
|
||||
{
|
||||
// Ohne diese Regel antworten in einem Raum mit drei Agenten alle drei auf
|
||||
// jede Nachricht. Direktnachrichten sind immer gemeint.
|
||||
candidates = candidates
|
||||
.Where(r => r.IsDirect || r.UserMentions > 0 || r.GroupMentions > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
candidates = candidates
|
||||
.Where(r => config.IsRoomAllowed(r.RoomId) || config.IsRoomAllowed(r.Name))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return ToolJobResult.NoAction("Nichts Neues");
|
||||
|
||||
var blocks = new List<string>();
|
||||
var total = 0;
|
||||
|
||||
foreach (var room in candidates)
|
||||
{
|
||||
var messages = await CollectNewMessagesAsync(client, config, stateStore, agent, room, logger, ct);
|
||||
|
||||
if (messages.Count > 0
|
||||
&& await TryConsumeWakeBudgetAsync(stateStore, agent, room, config, logger, ct))
|
||||
{
|
||||
total += messages.Count;
|
||||
blocks.Add(FormatRoom(room, messages));
|
||||
}
|
||||
|
||||
// Jeden geprüften Raum als gelesen markieren — auch wenn nichts zu wecken
|
||||
// war. Sonst bliebe eine alte, nie gelesene Erwähnung stehen und der Raum
|
||||
// wäre bei jedem Takt erneut ein Kandidat.
|
||||
try
|
||||
{
|
||||
await client.MarkReadAsync(room.RoomId, ct);
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
logger.LogWarning("RocketChat: Raum {Room} konnte nicht als gelesen markiert werden: {Message}",
|
||||
room.Label, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.Count == 0)
|
||||
return ToolJobResult.NoAction("Nichts Neues (nach Filterung)");
|
||||
|
||||
logger.LogInformation("RocketChat: {Count} neue Nachricht(en) in {Rooms} Raum/Räumen",
|
||||
total, blocks.Count);
|
||||
|
||||
return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} neue Nachricht(en)");
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
logger.LogWarning("RocketChat-Abruf fehlgeschlagen: {Message}", ex.Message);
|
||||
return ToolJobResult.NoAction($"Abruf-Fehler: {ex.Message}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogWarning("Rocket.Chat ist nicht erreichbar: {Message}", ex.Message);
|
||||
return ToolJobResult.NoAction($"Nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return ToolJobResult.NoAction("Zeitüberschreitung beim Abruf");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt die neuen Nachrichten eines Raums und filtert alles heraus, was keinen Agenten
|
||||
/// wecken darf: eigene Beiträge, Systemmeldungen und — sofern nicht namentlich
|
||||
/// erwähnt — Beiträge anderer Agenten. Der letzte Punkt ist die Bremse gegen
|
||||
/// Agentenschleifen: Ohne ihn antwortet A auf B auf A.
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<RoomMessage>> CollectNewMessagesAsync(
|
||||
RocketChatClient client, RocketChatConfig config, IStateStore stateStore,
|
||||
string agent, RoomSubscription room, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var markerKey = $"rocketchat:{agent}:{room.RoomId}:lastTs";
|
||||
var markerRaw = await stateStore.GetAsync(markerKey, ct);
|
||||
|
||||
DateTime since;
|
||||
if (string.IsNullOrWhiteSpace(markerRaw))
|
||||
{
|
||||
// Erster Abruf für diesen Raum. Nicht überspringen — ein Raum wird erst dann
|
||||
// zum Kandidaten, wenn Verkehr da ist, und genau die auslösende Nachricht
|
||||
// würde sonst verschluckt. Stattdessen begrenzt zurückschauen: neu genug, um
|
||||
// das Aktuelle zu erwischen, kurz genug, um beim Einrichten nicht die halbe
|
||||
// Raumgeschichte in den Kontext zu kippen.
|
||||
since = DateTime.UtcNow - config.InitialLookback;
|
||||
logger.LogInformation(
|
||||
"RocketChat: erster Abruf für {Room} — {Minutes} Minuten Rückschau",
|
||||
room.Label, config.InitialLookback.TotalMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
since = DateTime.TryParse(markerRaw, null,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: DateTime.UtcNow - config.InitialLookback;
|
||||
}
|
||||
|
||||
var history = await client.GetHistoryAsync(room, since, config.MaxMessagesPerRoom, ct);
|
||||
if (history.Count == 0)
|
||||
return [];
|
||||
|
||||
// Die Marke wandert auf die jüngste *gesehene* Nachricht — auch auf gefilterte.
|
||||
// Sonst würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
|
||||
await stateStore.SetAsync(markerKey, history[^1].Timestamp.ToString("O"), ct);
|
||||
|
||||
return history
|
||||
.Where(m => !m.IsSystem)
|
||||
.Where(m => m.UserId != config.UserId)
|
||||
// Die Erwähnungsprüfung gehört an die einzelne Nachricht, nicht an den Raum:
|
||||
// Der Zähler aus subscriptions.get zählt *ungelesene* Erwähnungen und bleibt
|
||||
// stehen, solange nichts gelesen wurde. Eine einzige alte Erwähnung würde den
|
||||
// Raum sonst dauerhaft „heiß" halten und jede weitere Nachricht ausliefern.
|
||||
.Where(m => !config.MentionOnly || room.IsDirect || IsMentioned(m, config))
|
||||
.Where(m => !config.IsAgentUser(m.Username) || IsMentioned(m, config))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erwähnungen liefert Rocket.Chat als Benutzernamen — verglichen wird deshalb gegen
|
||||
/// <c>username</c>, nicht gegen die Benutzer-Id.
|
||||
///
|
||||
/// Ist kein Benutzername konfiguriert, gilt eine Nachricht als nicht erwähnt. Das ist
|
||||
/// die vorsichtige Richtung: Im Zweifel weckt die Nachricht eines anderen Agenten
|
||||
/// diesen hier nicht, statt eine Antwortschleife zu riskieren.
|
||||
/// </summary>
|
||||
private static bool IsMentioned(RoomMessage message, RocketChatConfig config)
|
||||
=> message.Mentions.Any(m =>
|
||||
// @all und @here meinen ausdrücklich auch diesen Agenten.
|
||||
string.Equals(m, "all", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m, "here", StringComparison.OrdinalIgnoreCase)
|
||||
|| (!string.IsNullOrWhiteSpace(config.Username)
|
||||
&& string.Equals(m, config.Username, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
/// <summary>
|
||||
/// Deckelt, wie oft ein Raum den Agenten je Stunde wecken darf. Jeder Weckvorgang ist
|
||||
/// ein bezahlter Lauf; ohne Deckel wird ein lebhafter Raum zur Kostenfalle.
|
||||
/// </summary>
|
||||
private static async Task<bool> TryConsumeWakeBudgetAsync(
|
||||
IStateStore stateStore, string agent, RoomSubscription room,
|
||||
RocketChatConfig config, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
if (config.MaxWakesPerRoomPerHour <= 0)
|
||||
return true;
|
||||
|
||||
var bucket = DateTime.UtcNow.ToString("yyyyMMddHH");
|
||||
var key = $"rocketchat:{agent}:{room.RoomId}:wakes:{bucket}";
|
||||
|
||||
var used = int.TryParse(await stateStore.GetAsync(key, ct), out var n) ? n : 0;
|
||||
if (used >= config.MaxWakesPerRoomPerHour)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"RocketChat: Weckgrenze für {Room} erreicht ({Max}/Stunde) — Nachrichten bleiben ungelesen",
|
||||
room.Label, config.MaxWakesPerRoomPerHour);
|
||||
return false;
|
||||
}
|
||||
|
||||
await stateStore.SetAsync(key, (used + 1).ToString(), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatRoom(RoomSubscription room, IReadOnlyList<RoomMessage> messages)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Raum: {room.Label} (roomId: {room.RoomId})");
|
||||
|
||||
foreach (var m in messages)
|
||||
{
|
||||
sb.AppendLine($"[{m.Timestamp:HH:mm}] @{m.Username} (messageId: {m.Id}): {m.Text}");
|
||||
|
||||
// Ohne diese Zeilen sähe der Agent bei einer reinen Dateisendung nur einen
|
||||
// leeren Beitrag — die Datei liegt nicht im Text, sondern daneben.
|
||||
foreach (var f in m.Files)
|
||||
sb.AppendLine($" Datei: {f.Describe()}");
|
||||
|
||||
foreach (var l in m.Links)
|
||||
sb.AppendLine($" Link: {l.Url}"
|
||||
+ (l.Title is null ? "" : $" — {Shorten(l.Title, 120)}"));
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string Shorten(string value, int max)
|
||||
{
|
||||
var flat = value.ReplaceLineEndings(" ").Trim();
|
||||
return flat.Length <= max ? flat : flat[..max] + "…";
|
||||
}
|
||||
|
||||
private static string BuildWakeMessage(IReadOnlyList<string> blocks)
|
||||
=> $"""
|
||||
Neue Nachrichten in Rocket.Chat.
|
||||
|
||||
{Untrusted(string.Join("\n\n", blocks))}
|
||||
|
||||
Wenn du antworten möchtest, nutze das Tool RocketChat mit der roomId aus dem
|
||||
jeweiligen Block (send_message), oder reply mit der messageId als threadId.
|
||||
""";
|
||||
|
||||
private static string Untrusted(string content)
|
||||
=> $"<untrusted_content>\n{content}\n</untrusted_content>";
|
||||
|
||||
// ─── Eingabe-Helfer ───
|
||||
|
||||
private static string? GetString(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private static int? GetInt(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
&& v.TryGetInt32(out var i)
|
||||
? i
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Workspace-Pfad mit den im Projekt üblichen Prefixen auf
|
||||
/// (<c>personal:</c>, <c>shared:</c>, ohne Prefix = persönlicher Workspace) — gleiche
|
||||
/// Schreibweise wie beim FileRW- und FTP-Tool, damit ein Agent nicht je Tool umdenken muss.
|
||||
///
|
||||
/// Die Prüfung gegen einen Ausbruch aus dem Verzeichnis ist der eigentliche Zweck: Ohne sie
|
||||
/// könnte ein Agent per <c>../../</c> beliebige Dateien des Rechners in einen Chatraum
|
||||
/// schieben.
|
||||
/// </summary>
|
||||
internal static class WorkspaceFile
|
||||
{
|
||||
public sealed record Resolved(string FullPath, string Label);
|
||||
|
||||
public static (Resolved? File, string? Error) Resolve(
|
||||
string path, string? personalRoot, string? sharedRoot)
|
||||
{
|
||||
string relative;
|
||||
string? root;
|
||||
string label;
|
||||
|
||||
if (path.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = path[7..];
|
||||
root = sharedRoot;
|
||||
label = "shared";
|
||||
}
|
||||
else if (path.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = path[9..];
|
||||
root = personalRoot;
|
||||
label = "personal";
|
||||
}
|
||||
else
|
||||
{
|
||||
relative = path;
|
||||
root = personalRoot;
|
||||
label = "personal";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
return (null, $"Kein {label}-Workspace konfiguriert.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
return (null, "Es wurde kein Dateiname angegeben.");
|
||||
|
||||
// Auf Windows trennt ':' einen NTFS-Alternativdatenstrom ab. Ein Pfad wie
|
||||
// "bericht.txt:versteckt" schreibt dann neben die sichtbare Datei, und die
|
||||
// Verzeichnisprüfung unten würde ihn trotzdem durchwinken.
|
||||
if (relative.Contains(':'))
|
||||
return (null, "Doppelpunkte sind im Pfad nicht erlaubt.");
|
||||
|
||||
var basePath = Path.GetFullPath(root);
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(Path.Combine(basePath, relative));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return (null, $"Ungültiger Pfad: {path}");
|
||||
}
|
||||
|
||||
// Der abschließende Trenner verhindert, dass "…/workspace-geheim" als Treffer
|
||||
// für "…/workspace" durchgeht.
|
||||
var fence = basePath.EndsWith(Path.DirectorySeparatorChar)
|
||||
? basePath
|
||||
: basePath + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!fullPath.StartsWith(fence, StringComparison.OrdinalIgnoreCase))
|
||||
return (null, $"Zugriff außerhalb des {label}-Workspace verweigert.");
|
||||
|
||||
return (new Resolved(fullPath, label), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MIME-Typ nach Dateiendung. Rocket.Chat zeigt Bilder und PDFs nur dann in der
|
||||
/// Vorschau, wenn der Typ stimmt — <c>application/octet-stream</c> landet immer als
|
||||
/// stummer Download.
|
||||
/// </summary>
|
||||
public static string GuessContentType(string fileName) =>
|
||||
Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".txt" or ".log" => "text/plain",
|
||||
".md" => "text/markdown",
|
||||
".csv" => "text/csv",
|
||||
".json" => "application/json",
|
||||
".xml" => "application/xml",
|
||||
".html" or ".htm" => "text/html",
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".zip" => "application/zip",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".odt" => "application/vnd.oasis.opendocument.text",
|
||||
".ods" => "application/vnd.oasis.opendocument.spreadsheet",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user