Files
ClawdDotNet/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs
T
RichardandClaude Opus 5.5 ad71682c2d i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)
Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:43:51 +02:00

522 lines
18 KiB
C#

using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolItemViewModel : ViewModelBase
{
public string Name { get; }
/// <summary>
/// Kurzbeschreibung aus der Werkzeug-Registrierung. Leer, wenn das Werkzeug in dieser
/// Instanz gar nicht geladen ist — die Liste der bekannten Namen ist länger als die
/// der tatsächlich vorhandenen Werkzeuge.
/// </summary>
public string Description { get; }
[ObservableProperty]
private bool _isEnabled;
public ToolItemViewModel(string name, bool isEnabled, string description = "")
{
Name = name;
Description = description;
_isEnabled = isEnabled;
}
}
public sealed partial class AgentItemViewModel : ViewModelBase
{
public AgentConfig Config { get; }
public string AgentId => Config.AgentId;
[ObservableProperty]
private string _displayName;
[ObservableProperty]
private string _model;
[ObservableProperty]
private string _systemPrompt;
[ObservableProperty]
private string _identity;
[ObservableProperty]
private string _soul;
[ObservableProperty]
private string _description;
[ObservableProperty]
private string _promptCaching;
[ObservableProperty]
private int _maxToolResultChars;
[ObservableProperty]
private int _maxSteps;
[ObservableProperty]
private int _maxContextTokens;
[ObservableProperty]
private int _timeoutSeconds;
[ObservableProperty]
private decimal _dailyCostUsd;
[ObservableProperty]
private long _dailyTokens;
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
/// <summary>
/// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht
/// dieselbe, und zwei gepflegte Listen wären eine zu viel.
/// </summary>
public static IReadOnlyList<string> KnownTools => AgentAdministration.KnownTools;
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
Config = config;
_displayName = config.DisplayName;
_model = config.Model;
_systemPrompt = config.SystemPrompt;
_identity = config.Identity;
_soul = config.Soul;
_description = config.Description;
_promptCaching = config.PromptCaching;
_maxToolResultChars = config.MaxToolResultChars;
_maxSteps = config.LoopGuard.MaxSteps;
_maxContextTokens = config.LoopGuard.MaxContextTokens;
_timeoutSeconds = config.LoopGuard.TimeoutSeconds;
_dailyCostUsd = config.Budget.DailyCostUsd;
_dailyTokens = config.Budget.DailyTokens;
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var description = descriptions?.GetValueOrDefault(tool) ?? "";
var item = new ToolItemViewModel(tool, isEnabled, description);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
{
if (item.IsEnabled && !Config.Tools.ContainsKey(tool))
Config.Tools[tool] = new Dictionary<string, object?>();
else if (!item.IsEnabled)
Config.Tools.Remove(tool);
}
};
AvailableTools.Add(item);
}
}
public void ApplyChanges()
{
Config.DisplayName = DisplayName;
Config.Model = Model;
Config.SystemPrompt = SystemPrompt;
Config.Identity = Identity;
Config.Soul = Soul;
Config.Description = Description;
Config.PromptCaching = PromptCaching;
Config.MaxToolResultChars = MaxToolResultChars;
Config.LoopGuard.MaxSteps = MaxSteps;
Config.LoopGuard.MaxContextTokens = MaxContextTokens;
Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
Config.Budget.DailyCostUsd = DailyCostUsd;
Config.Budget.DailyTokens = DailyTokens;
}
}
/// <summary>Welches Feld der Texteditor gerade bearbeitet.</summary>
public enum AgentTextField
{
Identity,
Soul
}
/// <summary>
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
///
/// <para>Der Entwurf ordnet die Seite wieder wie die WinForms-Vorlage in vier Felder an:
/// oben Agentenliste und Agenteneinstellungen, unten Werkzeugliste und
/// Werkzeugeinstellungen. Für dieses Modell heißt das vor allem: Die Werkzeugeinstellungen
/// sind kein eigenes Fenster mehr, sondern hängen als <see cref="ToolSettings"/> an der
/// Auswahl in der Werkzeugliste.</para>
/// </summary>
public sealed partial class AgentsPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
public ObservableCollection<AgentItemViewModel> Agents { get; } = [];
[ObservableProperty]
private AgentItemViewModel? _selectedAgent;
/// <summary>
/// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten
/// rechts stehen.
/// </summary>
[ObservableProperty]
private ToolItemViewModel? _selectedTool;
/// <summary>
/// Die Felder zum ausgewählten Werkzeug. Wird bei jedem Wechsel neu gebaut — das
/// Ansichtsmodell trägt die Werte von einem guten Dutzend Werkzeuge, und eines davon
/// mit den Werten eines anderen weiterzuverwenden ginge irgendwann schief.
/// </summary>
[ObservableProperty]
private ToolSettingsViewModel? _toolSettings;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunning;
public List<string> KnownModels { get; } =
[
"anthropic/claude-3-5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1"
];
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
/// <summary>
/// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück,
/// wurde gespeichert; <c>null</c> heißt Abbruch.
/// </summary>
public event Func<string, string, Task<string?>>? RequestTextEditorDialog;
public AgentsPageViewModel() : this(null) { }
public AgentsPageViewModel(AppHost? host) : base("nav.agents")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
// Seit es eine zweite Oberflaeche gibt, ist diese Liste nicht mehr die
// einzige Quelle von Aenderungen: Ein im Browser angelegter Agent stuende
// sonst in der Datei und nicht im Fenster. Das Ereignis kommt auf dem Faden
// des Verursachers — deshalb ueber den Dispatcher.
//
// Kein Abmelden: Diese Seite lebt so lange wie die Anwendung.
host.InstanceChanged += change =>
{
if (change.Kind == InstanceChangeKind.Agents)
Dispatcher.UIThread.Post(ReloadAgents);
};
}
else
{
StatusText = LocalizationManager.Instance["desktop.status.design"];
}
LocalizationManager.Instance.LanguageChanged += () => OnPropertyChanged(nameof(AllowedToolsHeader));
}
/// <summary>
/// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in
/// <see cref="AgentItemViewModel.KnownTools"/> ist fest verdrahtet und kennt auch
/// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die
/// Beschreibung eben leer.
/// </summary>
private IReadOnlyDictionary<string, string> ToolDescriptions()
=> _host?.AgentAdmin.ToolDescriptions() ?? new Dictionary<string, string>();
/// <summary>
/// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt.
///
/// <para>Die Auswahl zu behalten ist kein Feinschliff: Der Aufbau laeuft jetzt auch,
/// wenn die Weboberflaeche etwas geaendert hat. Waere die Auswahl danach jedesmal
/// weg, spraenge dem Benutzer der Cursor aus der Zeile, waehrend er arbeitet.</para>
/// </summary>
[RelayCommand]
private void ReloadAgents()
{
if (_host is null)
{
Agents.Clear();
return;
}
var previous = SelectedAgent?.AgentId;
Agents.Clear();
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault(a => a.AgentId == previous) ?? Agents.FirstOrDefault();
}
public string AllowedToolsHeader => SelectedAgent is not null
? LocalizationManager.Instance.Get("desktop.agents.allowedToolsFor", SelectedAgent.DisplayName)
: "";
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
{
// Die Werkzeugliste unten gehört zum gewählten Agenten — bei einem Wechsel darf
// dort nicht die Auswahl des vorigen stehen bleiben.
SelectedTool = value?.AvailableTools.FirstOrDefault();
OnPropertyChanged(nameof(AllowedToolsHeader));
}
partial void OnSelectedToolChanged(ToolItemViewModel? value)
{
if (SelectedAgent is null || value is null)
{
ToolSettings = null;
return;
}
// Auch für ein noch nicht zugelassenes Werkzeug sollen die Felder sichtbar sein —
// sonst müsste man erst ankreuzen, um zu sehen, was einzustellen wäre.
if (!SelectedAgent.Config.Tools.TryGetValue(value.Name, out var config) || config is null)
config = new Dictionary<string, object?>();
ToolSettings = new ToolSettingsViewModel(value.Name, config);
}
[RelayCommand]
private async Task AddAgentAsync()
{
if (_host is null || RequestAddAgentDialog is null) return;
var vm = new AddAgentViewModel();
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
// Erst die offenen Änderungen der Liste sichern, dann anlegen: Der Aufruf
// schreibt die Instanzdatei, und was hier noch nicht übernommen war, ginge sonst
// verloren.
ApplyPendingEdits();
var outcome = _host.AgentAdmin.Add(
new NewAgent(result.AgentId, result.DisplayName, result.Model, result.Description));
StatusText = outcome.Message;
if (!outcome.Ok) return;
if (_host.AgentAdmin.Find(result.AgentId) is not { } newConfig) return;
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
}
[RelayCommand]
private void RemoveAgent()
{
if (_host is null || SelectedAgent is null) return;
var removed = SelectedAgent;
ApplyPendingEdits();
var outcome = _host.AgentAdmin.Remove(removed.AgentId);
StatusText = outcome.Message;
if (!outcome.Ok) return;
Agents.Remove(removed);
SelectedAgent = Agents.FirstOrDefault();
}
/// <summary>
/// Weckt den gewählten Agenten von Hand — im Entwurf die Schaltfläche „Jetzt ausf.".
/// Nimmt dieselbe Nachricht wie der geplante Lauf, damit der Handlauf zeigt, was der
/// geplante täte.
/// </summary>
[RelayCommand]
private async Task RunAgentNowAsync()
{
if (_host is null || SelectedAgent is null) return;
if (_host.Engine is null)
{
StatusText = LocalizationManager.Instance["desktop.chat.noApiKey"];
return;
}
var config = SelectedAgent.Config;
IsRunning = true;
StatusText = $"Running '{config.DisplayName}'…";
try
{
var outcome = await _host.Runs.RunAgentAsync(
config.AgentId, config.Scheduler?.TaskMessage, CancellationToken.None);
StatusText = outcome.Message;
if (!outcome.Ok)
_logger?.LogWarning("Agent execution failed: {Reason}", outcome.Message);
}
finally
{
IsRunning = false;
}
}
[RelayCommand]
private Task EditIdentityAsync() => EditTextFieldAsync(AgentTextField.Identity);
[RelayCommand]
private Task EditSoulAsync() => EditTextFieldAsync(AgentTextField.Soul);
/// <summary>
/// Identity und Soul sind lange Texte. Der Entwurf gibt ihnen ein eigenes kleines
/// Fenster statt eines Feldes in der Spalte — in einem 60 Pixel hohen Kasten lässt
/// sich eine Rollenbeschreibung nicht schreiben.
/// </summary>
private async Task EditTextFieldAsync(AgentTextField field)
{
if (SelectedAgent is null || RequestTextEditorDialog is null) return;
var isIdentity = field == AgentTextField.Identity;
var title = isIdentity
? $"Edit Identity — {SelectedAgent.DisplayName}"
: $"Edit Soul — {SelectedAgent.DisplayName}";
var current = isIdentity ? SelectedAgent.Identity : SelectedAgent.Soul;
var edited = await RequestTextEditorDialog(title, current);
if (edited is null) return;
if (isIdentity) SelectedAgent.Identity = edited;
else SelectedAgent.Soul = edited;
SaveAgents();
}
/// <summary>Öffnet den Arbeitsordner des gewählten Agenten.</summary>
[RelayCommand]
private void OpenAgentFolder()
{
if (_host is null || SelectedAgent is null) return;
var path = _host.Directories.GetAgentPath(_host.InstancePath, SelectedAgent.DisplayName);
if (!Directory.Exists(path))
{
StatusText = $"Folder not found: {path}";
return;
}
SystemShell.OpenFolder(path);
}
/// <summary>
/// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration.
///
/// <para>Das Ankreuzfeld in der Liste und diese Schaltfläche sind zwei verschiedene
/// Dinge: Ersteres lässt das Werkzeug zu, letztere schreibt seine Einstellungen. Wer
/// nur ankreuzt, bekommt ein Werkzeug mit Vorgabewerten — das ist gewollt.</para>
/// </summary>
[RelayCommand]
private void SaveToolSettings()
{
if (_host is null || SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen — das Ankreuzfeld
// zieht nach, damit Liste und Konfiguration nicht auseinanderlaufen.
SelectedTool.IsEnabled = true;
ApplyPendingEdits();
StatusText = _host.AgentAdmin
.SetToolSettings(SelectedAgent.AgentId, SelectedTool.Name, config).Message;
}
[RelayCommand]
private void SaveAgents()
{
if (_host is null) return;
ApplyPendingEdits();
_host.AgentAdmin.Save();
_logger?.LogInformation("Agents configuration saved.");
StatusText = LocalizationManager.Instance["desktop.agents.saved"];
}
/// <summary>
/// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte.
///
/// <para>Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei
/// <c>ApplyChanges</c> zurück. Jeder Aufruf, der gleich die Instanzdatei schreibt,
/// muss vorher hier durch — sonst verschwindet eine gerade getippte Änderung, weil
/// jemand nebenbei einen Agenten angelegt hat.</para>
/// </summary>
private void ApplyPendingEdits()
{
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
}
// ─── Teilerstellungen ───
//
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
// Verhaeltnis, kein Pixelwert: Sonst sitzt der Teiler nach einem Wechsel auf einen
// anderen Bildschirm an einer voellig anderen Stelle.
public double SplitVertical => Clamp(_host?.Settings.AppSettings.AgentsSplitVertical ?? 0.55);
public double SplitTopList => Clamp(_host?.Settings.AppSettings.AgentsSplitTopList ?? 0.44);
public double SplitBottomList => Clamp(_host?.Settings.AppSettings.AgentsSplitBottomList ?? 0.44);
/// <summary>
/// Hält den Teiler in einem Bereich, in dem beide Seiten noch bedienbar sind. Ein
/// gespeicherter Wert von 0.02 hätte eine Spalte auf einen Strich zusammengezogen und
/// wäre ohne Zurücksetzen nicht mehr aufzuziehen gewesen.
/// </summary>
private static double Clamp(double ratio)
=> double.IsFinite(ratio) ? Math.Clamp(ratio, 0.2, 0.8) : 0.5;
public void SaveSplits(double vertical, double topList, double bottomList)
{
if (_host is null) return;
try
{
var settings = _host.Settings.AppSettings;
settings.AgentsSplitVertical = Clamp(vertical);
settings.AgentsSplitTopList = Clamp(topList);
settings.AgentsSplitBottomList = Clamp(bottomList);
_host.Settings.Save();
}
catch (Exception)
{
// Eine Teilerstellung ist es nicht wert, die Bedienung zu unterbrechen.
}
}
}