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>
92 lines
4.2 KiB
C#
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;
|
|
}
|
|
}
|