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:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 33d95a6c3f
88 changed files with 11665 additions and 1029 deletions
@@ -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 &gt; Instanz-Ordner oeffnen". Der Entwurf
/// kennt keine Menueleiste mehr; der Befehl sitzt jetzt dort, wo auch der Pfad steht.
/// </summary>
[RelayCommand]
private void OpenInstanceFolder()
{
if (!Directory.Exists(InstancePath)) return;
SystemShell.OpenFolder(InstancePath);
}
private void LoadAssemblyBuildInfos()
{
Assemblies.Clear();
@@ -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} €";
}
}