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>
522 lines
18 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|