feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)
Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen (LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff). upload_worksheet/update_worksheet bewusst nicht umgesetzt: das Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert, damit Dialog und MCP-Tool nicht auseinanderdriften. Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude Desktops claude_desktop_config.json ein (Button in den Einstellungen, nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne eine nicht lesbare Konfigurationsdatei zu überschreiben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -225,7 +225,9 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<TimeEntryTools>();
|
||||
services.AddSingleton<LessonPlanTools>();
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
services.AddSingleton<McpClientRegistrationService>();
|
||||
|
||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||
var untisSettings = new WebUntisSettingsService(appData);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
|
||||
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
|
||||
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
|
||||
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
|
||||
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
|
||||
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
|
||||
/// </summary>
|
||||
public static class LetterPlaceholderBuilder
|
||||
{
|
||||
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
||||
Student student, Contact? contact, LearningGroup? group, DateOnly date,
|
||||
string letterText, string teacherName)
|
||||
{
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{
|
||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
public record McpRegistrationResult(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
|
||||
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
|
||||
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
|
||||
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
|
||||
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
|
||||
/// unterstützen ließe.
|
||||
///
|
||||
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
|
||||
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
|
||||
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
|
||||
///
|
||||
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
|
||||
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
|
||||
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
private const string ServerName = "lehrerapp";
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
private readonly string? _bridgePath;
|
||||
|
||||
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
|
||||
|
||||
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
|
||||
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
|
||||
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
|
||||
public McpClientRegistrationService(string? configPath, string? bridgePath)
|
||||
{
|
||||
ConfigPath = configPath;
|
||||
_bridgePath = bridgePath;
|
||||
}
|
||||
|
||||
public string? ConfigPath { get; }
|
||||
|
||||
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
|
||||
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
|
||||
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
|
||||
|
||||
public bool IsRegistered() =>
|
||||
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
|
||||
|
||||
public McpRegistrationResult Register()
|
||||
{
|
||||
if (ConfigPath is null)
|
||||
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
|
||||
|
||||
var bridgePath = _bridgePath;
|
||||
if (bridgePath is null || !File.Exists(bridgePath))
|
||||
return new(false,
|
||||
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
|
||||
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
|
||||
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
|
||||
if (root["mcpServers"] is not JsonObject servers)
|
||||
{
|
||||
servers = new JsonObject();
|
||||
root["mcpServers"] = servers;
|
||||
}
|
||||
servers[ServerName] = new JsonObject
|
||||
{
|
||||
["command"] = bridgePath,
|
||||
["args"] = new JsonArray(),
|
||||
};
|
||||
|
||||
var detectedBefore = IsClaudeDesktopDetected;
|
||||
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
|
||||
if (result.Success && !detectedBefore)
|
||||
return result with
|
||||
{
|
||||
Message = result.Message +
|
||||
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
|
||||
"greift erst, sobald Claude Desktop installiert ist.",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
public McpRegistrationResult Unregister()
|
||||
{
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath))
|
||||
return new(true, "Nichts einzutragen gefunden.");
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
|
||||
return new(true, "War nicht eingetragen.");
|
||||
return WriteRoot(root, "Eintrag entfernt.");
|
||||
}
|
||||
|
||||
private bool TryLoadRoot(out JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
|
||||
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
|
||||
$"Bitte manuell prüfen: {ConfigPath}";
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
|
||||
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
|
||||
return new(true, successMessage);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBridgePath()
|
||||
{
|
||||
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
|
||||
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
|
||||
}
|
||||
|
||||
private static string? ResolveConfigPath()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Claude", "claude_desktop_config.json");
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Library", "Application Support", "Claude", "claude_desktop_config.json");
|
||||
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,13 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
McpSettingsService settings, AppLogger logger,
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools)
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_serverOptions = BuildServerOptions(
|
||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||
groupMembershipTools);
|
||||
groupMembershipTools, letterTemplateTools);
|
||||
}
|
||||
|
||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||
@@ -113,7 +113,7 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
private static McpServerOptions BuildServerOptions(
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools)
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
||||
{
|
||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
|
||||
@@ -155,6 +155,10 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
||||
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
|
||||
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
|
||||
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
|
||||
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||
|
||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||
|
||||
@@ -19,6 +19,8 @@ public static class McpToolScope
|
||||
"get_time_entries",
|
||||
"get_lesson_plans",
|
||||
"download_lesson_attachment",
|
||||
"list_letter_templates",
|
||||
"render_letter",
|
||||
];
|
||||
|
||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||
|
||||
@@ -52,3 +52,12 @@ public record GroupMembershipDto(
|
||||
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
|
||||
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
|
||||
public record WriteResultDto(bool Applied, Guid? Id, string Message);
|
||||
|
||||
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
|
||||
|
||||
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
|
||||
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
|
||||
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
|
||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||
|
||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
|
||||
/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
|
||||
/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
|
||||
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
|
||||
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
|
||||
/// (<c>CreateLetterDialogViewModel</c>). Bewusst NICHT umgesetzt: "upload_worksheet"/
|
||||
/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
|
||||
/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
|
||||
/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
|
||||
/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
|
||||
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
|
||||
public class LetterTemplateTools(
|
||||
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
|
||||
IStudentRepository students, IGroupRepository groups)
|
||||
{
|
||||
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
|
||||
public List<LetterTemplateDto> ListLetterTemplates()
|
||||
{
|
||||
var result = new List<LetterTemplateDto>();
|
||||
foreach (var installed in templates.GetTemplates())
|
||||
{
|
||||
try
|
||||
{
|
||||
var loaded = templates.Load(installed);
|
||||
result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
|
||||
loaded.Manifest.Placeholders
|
||||
.Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
|
||||
.ToList()));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{
|
||||
// Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
|
||||
// auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
|
||||
public LetterRenderResultDto RenderLetter(
|
||||
[Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Brieftext (Fließtext).")] string letterText,
|
||||
[Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
|
||||
[Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
|
||||
[Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
|
||||
[Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
|
||||
[Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
|
||||
Dictionary<string, string>? extraValues = null)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
|
||||
|
||||
var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
|
||||
if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
|
||||
|
||||
LoadedTemplate loaded;
|
||||
try { loaded = templates.Load(installed); }
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
{ return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
|
||||
|
||||
var contact = contactId is { } cid
|
||||
? student.Contacts.FirstOrDefault(c => c.Id == cid)
|
||||
: student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
|
||||
var group = groupId is { } gid ? groups.GetById(gid) : null;
|
||||
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName);
|
||||
foreach (var (name, raw) in extraValues ?? [])
|
||||
{
|
||||
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
|
||||
if (definition is null || definition.IsConstant) continue;
|
||||
if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
|
||||
}
|
||||
var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
|
||||
|
||||
var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
var missing = loaded.Manifest.Placeholders
|
||||
.Where(p => !p.IsConstant && p.Required &&
|
||||
(!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
|
||||
.Select(p => p.Name).ToList();
|
||||
if (!validation.IsValid || missing.Count > 0)
|
||||
{
|
||||
var issues = validation.Issues.Select(i => i.Message)
|
||||
.Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
|
||||
return new LetterRenderResultDto(false,
|
||||
"Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
|
||||
}
|
||||
|
||||
byte[] pdf;
|
||||
try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
|
||||
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
|
||||
{ return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
|
||||
|
||||
var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
|
||||
return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
|
||||
}
|
||||
|
||||
private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
|
||||
{
|
||||
PlaceholderType.Text => new TextValue(raw),
|
||||
PlaceholderType.Multiline => new MultilineValue(raw),
|
||||
PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
|
||||
PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
|
||||
// Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static DateOnly? TryParseDate(string raw) =>
|
||||
DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
|
||||
DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
|
||||
|
||||
private static decimal? TryParseNumber(string raw) =>
|
||||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
|
||||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class DictionaryDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
@@ -14,4 +16,27 @@ public partial class SettingsViewModel
|
||||
// Kein Live-Reload (siehe McpServerHostedService/Planungsdokument) - der Pipe-Listener wird
|
||||
// nur beim App-Start eingerichtet, deshalb wirkt eine Änderung hier erst nach Neustart.
|
||||
partial void OnMcpEnabledChanged(bool value) => _mcpSettings.SetEnabled(value);
|
||||
|
||||
// ── MCP: Registrierung bei Claude Desktop ─────────────────────────────────
|
||||
|
||||
[ObservableProperty] private string _mcpRegistrationStatus = "";
|
||||
[ObservableProperty] private bool _mcpIsRegisteredWithClaude;
|
||||
|
||||
private void LoadMcpRegistrationStatus() => McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||
|
||||
[RelayCommand]
|
||||
private void RegisterWithClaudeDesktop()
|
||||
{
|
||||
var result = _mcpRegistration.Register();
|
||||
McpRegistrationStatus = result.Message;
|
||||
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UnregisterFromClaudeDesktop()
|
||||
{
|
||||
var result = _mcpRegistration.Unregister();
|
||||
McpRegistrationStatus = result.Message;
|
||||
McpIsRegisteredWithClaude = _mcpRegistration.IsRegistered();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly McpSettingsService _mcpSettings;
|
||||
private readonly Services.Mcp.McpClientRegistrationService _mcpRegistration;
|
||||
private readonly WebUntisSettingsService _untisSettings;
|
||||
private readonly WebUntisIntegrationService? _untisIntegration;
|
||||
private readonly UntisSyncService? _untisSync;
|
||||
@@ -102,6 +103,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning, McpSettingsService mcpSettings,
|
||||
Services.Mcp.McpClientRegistrationService mcpRegistration,
|
||||
WebUntisSettingsService untisSettings,
|
||||
AnnualPlanSettingsService annualPlanSettings,
|
||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||
@@ -140,6 +142,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_mcpSettings = mcpSettings;
|
||||
_mcpRegistration = mcpRegistration;
|
||||
_untisSettings = untisSettings;
|
||||
_untisIntegration = untisIntegration;
|
||||
_untisSync = untisSync;
|
||||
@@ -168,6 +171,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadLetterTemplates();
|
||||
LoadAiSettings();
|
||||
LoadMcpSettings();
|
||||
LoadMcpRegistrationStatus();
|
||||
LoadUntisSettings();
|
||||
LoadAnnualPlanSettings();
|
||||
LoadSyncSettings();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Templating;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
@@ -88,27 +89,11 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var contact = SelectedContact?.Model; var group = SelectedGroup?.Model;
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName),
|
||||
["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
};
|
||||
}
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName);
|
||||
|
||||
private static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{ TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false };
|
||||
private static bool IsEmpty(PlaceholderValue value) => LetterPlaceholderBuilder.IsEmpty(value);
|
||||
private static string SanitizeFileName(string value)
|
||||
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
|
||||
}
|
||||
|
||||
@@ -1143,17 +1143,33 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: MCP-Server (lokal, Phase 1) -->
|
||||
<!-- Tab: MCP-Server (lokal) -->
|
||||
<ContentPage Header="MCP-Server">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
|
||||
|
||||
<TextBlock Text="MCP-Server" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung dieses Geräts über eine lokale Named Pipe. Es verlassen keine Daten das Gerät; Gesprächsnotizen, Vorfälle und Förderpläne sind nicht erreichbar. Eine Änderung wirkt erst nach einem Neustart der App."/>
|
||||
Text="Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Zugriff auf Schüler, Klausuren, Noten, Stundenplan, Zeiterfassung, Unterrichtsplanung und Elternbrief-Vorlagen dieses Geräts über eine lokale Named Pipe. Es verlassen keine Daten das Gerät; Gesprächsnotizen, Vorfälle und Förderpläne sind nicht erreichbar. Schreibende Aktionen zeigen immer erst einen Bestätigungsdialog, bevor etwas gespeichert wird. Eine Änderung hier wirkt erst nach einem Neustart der App."/>
|
||||
|
||||
<CheckBox Content="MCP-Server aktivieren" IsChecked="{Binding McpEnabled}"/>
|
||||
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,1,0,0" Margin="0,6,0,0" Padding="0,14,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Claude Desktop" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Trägt den Pfad zur MCP-Bridge dieser Installation in die Claude-Desktop-Konfiguration ein, damit Claude Desktop LehrerApp automatisch als MCP-Server findet. Funktioniert nur bei einer gepackten Installation, nicht bei einem lokalen Entwicklungs-Build."/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Bei Claude Desktop eintragen" Command="{Binding RegisterWithClaudeDesktopCommand}"
|
||||
IsVisible="{Binding !McpIsRegisteredWithClaude}"/>
|
||||
<Button Content="Eintrag entfernen" Command="{Binding UnregisterFromClaudeDesktopCommand}"
|
||||
IsVisible="{Binding McpIsRegisteredWithClaude}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding McpRegistrationStatus}" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding McpRegistrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
Reference in New Issue
Block a user