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:
@@ -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} €";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user