Files
LehrerApp/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs
T
adminandClaude Sonnet 5 19aa302487 feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.

LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 02:25:45 +02:00

92 lines
4.2 KiB
C#

using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Views.Mcp;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
/// <see cref="Dispatcher.UIThread"/>.
///
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
/// läuft `Topmost`.
///
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
///
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
/// Änderung als abgelehnt gewertet.
/// </summary>
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
private readonly object _trustLock = new();
private readonly HashSet<string> _trustedOperations = [];
private bool _trustAll;
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[CallerMemberName] string operationKey = "")
{
bool alreadyTrusted;
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
if (alreadyTrusted)
{
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
return true;
}
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
return false;
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
// sicher aufgerufen werden kann.
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
owner.Activate();
var dialog = new McpConfirmDialog
{
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
};
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
var timeoutTask = Task.Delay(Timeout, ct);
var completed = await Task.WhenAny(dialogTask, timeoutTask);
if (completed != dialogTask)
{
dialog.Close(McpConfirmDialogResult.Rejected);
return McpConfirmDialogResult.Rejected;
}
return await dialogTask;
});
if (result.Approved)
{
lock (_trustLock)
{
if (result.TrustAll) _trustAll = true;
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
}
}
return result.Approved;
}
}