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; } /// /// 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. /// 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 AvailableTools { get; } = []; /// /// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht /// dieselbe, und zwei gepflegte Listen wären eine zu viel. /// public static IReadOnlyList KnownTools => AgentAdministration.KnownTools; public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary? 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(); 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; } } /// Welches Feld der Texteditor gerade bearbeitet. public enum AgentTextField { Identity, Soul } /// /// Ansichtsmodell für die Agenten-Verwaltungsseite. /// /// 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 an der /// Auswahl in der Werkzeugliste. /// public sealed partial class AgentsPageViewModel : PageViewModel { private readonly AppHost? _host; private readonly ILogger? _logger; public ObservableCollection Agents { get; } = []; [ObservableProperty] private AgentItemViewModel? _selectedAgent; /// /// Das ausgewählte Werkzeug des ausgewählten Agenten. Steuert, welche Felder unten /// rechts stehen. /// [ObservableProperty] private ToolItemViewModel? _selectedTool; /// /// 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. /// [ObservableProperty] private ToolSettingsViewModel? _toolSettings; [ObservableProperty] private string _statusText = "Bereit"; [ObservableProperty] private bool _isRunning; public List 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 CachingOptions { get; } = ["auto", "on", "off"]; public event Func>? RequestAddAgentDialog; /// /// Öffnet den Editor für Identity bzw. Soul. Gibt der Aufrufer einen Text zurück, /// wurde gespeichert; null heißt Abbruch. /// public event Func>? 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)); } /// /// Beschreibungen der tatsächlich geladenen Werkzeuge. Die Namensliste in /// ist fest verdrahtet und kennt auch /// Werkzeuge, die in dieser Instanz nicht registriert sind — für die bleibt die /// Beschreibung eben leer. /// private IReadOnlyDictionary ToolDescriptions() => _host?.AgentAdmin.ToolDescriptions() ?? new Dictionary(); /// /// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt. /// /// 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. /// [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(); 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(); } /// /// 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. /// [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); /// /// 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. /// 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(); } /// Öffnet den Arbeitsordner des gewählten Agenten. [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); } /// /// Übernimmt die Felder des gewählten Werkzeugs in die Agentenkonfiguration. /// /// 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. /// [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"]; } /// /// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte. /// /// Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei /// ApplyChanges 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. /// 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); /// /// 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. /// 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. } } }