KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)
KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/) sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf, Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie als Update übernommen, sondern immer als neue Stunde behandelt. Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
This commit is contained in:
@@ -27,6 +27,13 @@ public static class AppBootstrapper
|
||||
public static string DbPath { get; private set; } = "";
|
||||
public static string AppDataPath { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Feste URL des KI-Backends (ai-backend/, TODO 4.5.9) — bewusst nicht in den Einstellungen
|
||||
/// editierbar, siehe Planungsdokument. Vor dem ersten produktiven Einsatz durch die tatsächlich
|
||||
/// deployte Domain ersetzen.
|
||||
/// </summary>
|
||||
public const string AiBackendUrl = "https://REPLACE_ME.example.com/";
|
||||
|
||||
/// <summary>
|
||||
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
|
||||
/// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
|
||||
@@ -147,6 +154,11 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
services.AddSingleton(_ => new AiSettingsService(appData));
|
||||
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
|
||||
services.AddSingleton<AiPlanningService>();
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
||||
public class AiBackendException(string userMessage) : Exception(userMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer <see cref="Unit"/>
|
||||
/// den Export-Kontext, ruft das externe PHP-Backend (ai-backend/) auf und wendet dessen Antwort auf
|
||||
/// die Lessons an. Das Backend selbst ruft serverseitig eine LLM-API auf — der Desktop-Client sieht
|
||||
/// nie einen LLM-API-Key, nur das eigene Bearer-Token gegen das PHP-Backend.
|
||||
/// </summary>
|
||||
public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
IGroupRepository groups, ISubjectRepository subjects,
|
||||
ICompetencyDomainRepository competencyDomains, IAlternativeLessonPathRepository altPaths)
|
||||
{
|
||||
// Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier
|
||||
// im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task<string> LoginAsync(string username, string password)
|
||||
{
|
||||
HttpResponseMessage resp;
|
||||
try
|
||||
{
|
||||
resp = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Benutzername oder Passwort ist falsch.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
|
||||
|
||||
var result = await resp.Content.ReadFromJsonAsync<LoginResult>(JsonOptions);
|
||||
return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes.");
|
||||
}
|
||||
|
||||
public async Task<decimal> GetBalanceAsync(string token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "status.php");
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Guthaben konnte nicht abgerufen werden.");
|
||||
|
||||
var result = await resp.Content.ReadFromJsonAsync<BalanceResult>(JsonOptions);
|
||||
return result?.BalanceUsd ?? 0m;
|
||||
}
|
||||
|
||||
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
|
||||
public AiUnitContext BuildContext(Unit unit, string instruction)
|
||||
{
|
||||
var group = groups.GetById(unit.GroupId);
|
||||
var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null;
|
||||
var gradeLevel = group?.GradeLevel ?? 0;
|
||||
|
||||
var competencyCatalog = subject is null
|
||||
? []
|
||||
: competencyDomains.GetBySubjectAndGrade(subject.Id, gradeLevel)
|
||||
.Select(d => new AiCompetencyDomain
|
||||
{
|
||||
Name = d.Name,
|
||||
Items = d.Items.Select(i => new AiCompetencyItem { Code = i.Code, Description = i.Description }).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var pathCatalog = altPaths.GetAll()
|
||||
.Select(p => new AiAlternativePath { Id = p.Id, Name = p.Name })
|
||||
.ToList();
|
||||
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||||
|
||||
var unitLessons = lessons.GetByUnit(unit.Id)
|
||||
.Select(l => new AiLesson
|
||||
{
|
||||
Id = l.Id,
|
||||
Date = l.Date,
|
||||
LessonNumber = l.LessonNumber,
|
||||
Topic = l.Topic,
|
||||
StartTime = l.StartTime,
|
||||
Homework = l.Homework,
|
||||
Reflection = l.Reflection,
|
||||
Phases = l.Phases.Select(p => new AiPhaseStep
|
||||
{
|
||||
Name = p.Name,
|
||||
DurationMinutes = p.DurationMinutes,
|
||||
Activity = p.Activity,
|
||||
Material = p.Material,
|
||||
Shorthand = p.Shorthand,
|
||||
AlternativePathName = p.AlternativePathId is { } pathId ? pathNames.GetValueOrDefault(pathId) : null,
|
||||
}).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new AiUnitContext
|
||||
{
|
||||
Id = unit.Id,
|
||||
Title = unit.Title,
|
||||
StartDate = unit.StartDate,
|
||||
EndDate = unit.EndDate,
|
||||
Competencies = unit.Competencies,
|
||||
Notes = unit.Notes,
|
||||
SubjectName = subject?.Name ?? "",
|
||||
GradeLevel = gradeLevel,
|
||||
GroupName = group?.Name ?? "",
|
||||
CompetencyCatalog = competencyCatalog,
|
||||
AlternativePathCatalog = pathCatalog,
|
||||
Lessons = unitLessons,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token)
|
||||
{
|
||||
var request = new AiPlanningRequest { Instruction = instruction, Unit = BuildContext(unit, instruction) };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "plan.php")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
|
||||
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
|
||||
/// <see cref="ILessonRepository.Save"/> je Eintrag auf. Eine akzeptierte AiLesson mit einer Id,
|
||||
/// die keiner tatsächlich zur Einheit gehörenden Lesson entspricht, wird NIE als Update
|
||||
/// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung).
|
||||
/// </summary>
|
||||
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons)
|
||||
{
|
||||
var existingIds = lessons.GetByUnit(unit.Id).Select(l => l.Id).ToHashSet();
|
||||
var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id);
|
||||
|
||||
var result = new List<Lesson>();
|
||||
foreach (var ai in acceptedLessons)
|
||||
{
|
||||
var isUpdate = ai.Id is { } id && existingIds.Contains(id);
|
||||
result.Add(new Lesson
|
||||
{
|
||||
Id = isUpdate ? ai.Id!.Value : Guid.NewGuid(),
|
||||
UnitId = unit.Id,
|
||||
GroupId = unit.GroupId,
|
||||
Date = ai.Date ?? DateOnly.FromDateTime(DateTime.Today),
|
||||
LessonNumber = ai.LessonNumber,
|
||||
Topic = ai.Topic,
|
||||
StartTime = ai.StartTime,
|
||||
Homework = ai.Homework,
|
||||
Reflection = ai.Reflection,
|
||||
Phases = ai.Phases.Select(p => new LessonPhaseStep
|
||||
{
|
||||
Name = p.Name,
|
||||
DurationMinutes = p.DurationMinutes,
|
||||
Activity = p.Activity,
|
||||
Material = p.Material,
|
||||
Shorthand = p.Shorthand,
|
||||
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
||||
? pathId : null,
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal class AiSettingsConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string? EncryptedToken { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen für die KI-gestützte Planungsunterstützung (TODO 4.5.9). Liegt in
|
||||
/// LehrerApp.Desktop statt LehrerApp.Core, weil die Token-Verschlüsselung <see cref="SyncCrypto"/>
|
||||
/// aus LehrerApp.Sync nutzt — Core bleibt bewusst frei von Abhängigkeiten außerhalb von .NET
|
||||
/// selbst (siehe CLAUDE.md), Sync hängt von Core ab, nicht umgekehrt.
|
||||
///
|
||||
/// Das Passwort wird nie persistiert, nur das nach erfolgreichem Login vom Backend ausgestellte
|
||||
/// Bearer-Token — und auch das nur verschlüsselt (AES-256-GCM über SyncCrypto, gleicher
|
||||
/// Mechanismus wie beim Sync-Schlüssel). Der Schlüssel selbst liegt dateirechte-geschützt
|
||||
/// (chmod 600 unter Unix) neben der Einstellungsdatei — kein Betriebssystem-Schlüsselbund, aber
|
||||
/// deutlich besser als die bisherige Klartext-Ablage der Sync-Server-URL.
|
||||
/// </summary>
|
||||
public class AiSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly string _keyPath;
|
||||
private readonly byte[] _tokenKey;
|
||||
private AiSettingsConfig _config;
|
||||
|
||||
public bool Enabled => _config.Enabled;
|
||||
public string Username => _config.Username;
|
||||
public bool IsLoggedIn => _config.EncryptedToken is not null;
|
||||
|
||||
public AiSettingsService(string appDataPath)
|
||||
{
|
||||
_configPath = Path.Combine(appDataPath, "ai-settings.json");
|
||||
_keyPath = Path.Combine(appDataPath, "ai-token.key");
|
||||
_tokenKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
|
||||
_config = Load();
|
||||
}
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
_config.Enabled = enabled;
|
||||
Save();
|
||||
}
|
||||
|
||||
public void SetCredentialsAndToken(string username, string token)
|
||||
{
|
||||
_config.Username = username;
|
||||
_config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey);
|
||||
Save();
|
||||
}
|
||||
|
||||
public string? GetToken() =>
|
||||
_config.EncryptedToken is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedToken, _tokenKey);
|
||||
|
||||
public void Logout()
|
||||
{
|
||||
_config.EncryptedToken = null;
|
||||
Save();
|
||||
}
|
||||
|
||||
private byte[] GenerateAndSaveKey()
|
||||
{
|
||||
var key = SyncCrypto.GenerateKey();
|
||||
SyncCrypto.SaveKey(key, _keyPath);
|
||||
return key;
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
|
||||
private AiSettingsConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<AiSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new AiSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardwert */ }
|
||||
return new AiSettingsConfig();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -29,6 +31,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly ISubjectRepository _subjects;
|
||||
private readonly ICompetencyDomainRepository _competencyDomains;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
|
||||
private Guid _groupId;
|
||||
|
||||
@@ -66,13 +69,15 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
||||
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||
|
||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||
IGroupRepository groups, ISubjectRepository subjects,
|
||||
ICompetencyDomainRepository competencyDomains)
|
||||
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings)
|
||||
{
|
||||
_units = units; _lessons = lessons; _groups = groups;
|
||||
_subjects = subjects; _competencyDomains = competencyDomains;
|
||||
_aiSettings = aiSettings;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, bool isReadOnly = false)
|
||||
@@ -121,6 +126,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
CopyUnitCommand.NotifyCanExecuteChanged();
|
||||
AddLessonCommand.NotifyCanExecuteChanged();
|
||||
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
|
||||
AiAssistCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void LoadLessons()
|
||||
@@ -144,6 +150,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
|
||||
private bool HasSelectedUnit() => SelectedUnit is not null;
|
||||
private bool HasSelectedLesson() => SelectedLesson is not null;
|
||||
private bool CanAiAssist() => SelectedUnit is not null && _aiSettings.Enabled;
|
||||
|
||||
// ── Einheiten (4.1) ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -253,6 +260,15 @@ public partial class PlanningTabViewModel : ObservableObject
|
||||
if (result is not null) LoadUnits();
|
||||
}
|
||||
|
||||
/// KI-gestützte Planungsunterstützung (4.5.9) — die eigentliche Anfrage/Auswertung läuft im
|
||||
/// Dialog (<see cref="AiAssistDialogViewModel"/>), hier wird nur nachgeladen.
|
||||
[RelayCommand(CanExecute = nameof(CanAiAssist))]
|
||||
private async Task AiAssist()
|
||||
{
|
||||
if (OnAiAssist is null || SelectedUnit is null) return;
|
||||
if (await OnAiAssist(SelectedUnit.Model)) LoadUnits();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||
private async Task EditLesson()
|
||||
{
|
||||
@@ -1067,6 +1083,91 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog: KI-gestützte Planungsunterstützung (4.5.9) ───────────────────────
|
||||
|
||||
/// Eine von der KI vorgeschlagene Stunde in der Prüfliste des Dialogs — angehakt = wird beim
|
||||
/// "Übernehmen" mit übertragen. <see cref="AiLesson.Id"/> unterscheidet neu/geändert (siehe
|
||||
/// AiPlanningDtos.cs), <see cref="IsNew"/> steuert hier nur die Anzeige ("Neu"/"Geändert").
|
||||
public partial class AiLessonReviewItem : ObservableObject
|
||||
{
|
||||
public AiLesson Source { get; }
|
||||
public bool IsNew { get; }
|
||||
public string DisplayLabel { get; }
|
||||
|
||||
[ObservableProperty] private bool _accepted = true;
|
||||
|
||||
public AiLessonReviewItem(AiLesson source, bool isNew)
|
||||
{
|
||||
Source = source;
|
||||
IsNew = isNew;
|
||||
var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum";
|
||||
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})";
|
||||
}
|
||||
}
|
||||
|
||||
public partial class AiAssistDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly Unit _unit;
|
||||
|
||||
[ObservableProperty] private string _instruction = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _errorMessage = "";
|
||||
[ObservableProperty] private bool _hasResults;
|
||||
[ObservableProperty] private string? _summary;
|
||||
|
||||
public string UnitSummary { get; }
|
||||
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
||||
public bool Result { get; private set; }
|
||||
|
||||
public AiAssistDialogViewModel(AiPlanningService aiPlanning, AiSettingsService aiSettings,
|
||||
ILessonRepository lessons, Unit unit)
|
||||
{
|
||||
_aiPlanning = aiPlanning; _aiSettings = aiSettings; _lessons = lessons; _unit = unit;
|
||||
var lessonCount = lessons.GetByUnit(unit.Id).Count;
|
||||
UnitSummary = $"Einheit: {unit.Title} — {lessonCount} Stunde(n)";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Send()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null)
|
||||
{
|
||||
ErrorMessage = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorMessage = ""; IsBusy = true;
|
||||
try
|
||||
{
|
||||
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token);
|
||||
var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet();
|
||||
|
||||
ReviewItems.Clear();
|
||||
foreach (var l in response.Lessons)
|
||||
ReviewItems.Add(new AiLessonReviewItem(l, isNew: l.Id is not { } id || !existingIds.Contains(id)));
|
||||
Summary = response.Summary;
|
||||
HasResults = true;
|
||||
}
|
||||
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Apply()
|
||||
{
|
||||
var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList();
|
||||
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted))
|
||||
_lessons.Save(lesson);
|
||||
Result = true;
|
||||
}
|
||||
|
||||
[RelayCommand] private void Cancel() => Result = false;
|
||||
}
|
||||
|
||||
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
||||
|
||||
public partial class CopyUnitDialogViewModel : ObservableObject
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LehrerApp.Core.Services;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
public partial class CompetencyCatalogImportViewModel : ObservableObject
|
||||
{
|
||||
private readonly SettingsViewModel _settings;
|
||||
private readonly CompetencyCatalogImportPreview _preview;
|
||||
|
||||
[ObservableProperty] private ImportModeOption _selectedMode;
|
||||
[ObservableProperty] private bool _replaceConfirmed;
|
||||
[ObservableProperty] private string _error = "";
|
||||
|
||||
public IReadOnlyList<ImportModeOption> Modes { get; }
|
||||
public ObservableCollection<CompetencyImportConflictItem> Conflicts { get; } = [];
|
||||
public IReadOnlyList<string> Warnings => _preview.Warnings;
|
||||
public string Target => $"{_preview.SubjectName} · Klassenstufe {_preview.GradeLevel}";
|
||||
public string Summary =>
|
||||
$"{_preview.NewDomains} neue Bereiche · {_preview.NewCompetencies} neue Kompetenzen · " +
|
||||
$"{_preview.UnchangedCompetencies} unverändert · {_preview.Conflicts.Count} Konflikte";
|
||||
public bool HasWarnings => Warnings.Count > 0;
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
public bool IsMerge => SelectedMode.Mode == CompetencyCatalogImportMode.Merge;
|
||||
public bool IsReplace => SelectedMode.Mode == CompetencyCatalogImportMode.Replace;
|
||||
|
||||
public CompetencyCatalogImportViewModel(
|
||||
SettingsViewModel settings, CompetencyCatalogImportPreview preview)
|
||||
{
|
||||
_settings = settings;
|
||||
_preview = preview;
|
||||
Modes =
|
||||
[
|
||||
new(CompetencyCatalogImportMode.Merge, "Zusammenführen (empfohlen)"),
|
||||
new(CompetencyCatalogImportMode.Replace, "Vorhandenen Katalog ersetzen"),
|
||||
];
|
||||
_selectedMode = Modes[0];
|
||||
foreach (var conflict in preview.Conflicts)
|
||||
Conflicts.Add(new CompetencyImportConflictItem(conflict));
|
||||
}
|
||||
|
||||
partial void OnSelectedModeChanged(ImportModeOption value)
|
||||
{
|
||||
Error = "";
|
||||
ReplaceConfirmed = false;
|
||||
OnPropertyChanged(nameof(IsMerge));
|
||||
OnPropertyChanged(nameof(IsReplace));
|
||||
}
|
||||
|
||||
public bool TryApply()
|
||||
{
|
||||
Error = "";
|
||||
if (IsReplace && !ReplaceConfirmed)
|
||||
{
|
||||
Error = "Bitte bestätige das vollständige Ersetzen des vorhandenen Katalogs.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var importedConflicts = Conflicts
|
||||
.Where(x => x.UseImported)
|
||||
.Select(x => x.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
_settings.ApplyCatalogImport(_preview, SelectedMode.Mode, importedConflicts);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ImportModeOption(CompetencyCatalogImportMode Mode, string Label);
|
||||
|
||||
public partial class CompetencyImportConflictItem : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _useImported;
|
||||
|
||||
public string Id { get; }
|
||||
public string Location { get; }
|
||||
public string ExistingValue { get; }
|
||||
public string ImportedValue { get; }
|
||||
|
||||
public CompetencyImportConflictItem(CompetencyCatalogImportConflict conflict)
|
||||
{
|
||||
Id = conflict.Id;
|
||||
Location = conflict.Location;
|
||||
ExistingValue = conflict.ExistingValue;
|
||||
ImportedValue = conflict.ImportedValue;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
@@ -154,12 +155,24 @@ public partial class SettingsViewModel : ObservableObject
|
||||
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
||||
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
|
||||
|
||||
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty] private bool _aiEnabled;
|
||||
[ObservableProperty] private string _aiUsername = "";
|
||||
[ObservableProperty] private string _aiPassword = "";
|
||||
[ObservableProperty] private string _aiLoginError = "";
|
||||
[ObservableProperty] private bool _aiIsLoggedIn;
|
||||
[ObservableProperty] private string _aiBalanceDisplay = "";
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||
private readonly PeriodScheduleService _periodSchedule;
|
||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
private readonly AiPlanningService _aiPlanning;
|
||||
private readonly CompetencyCatalogImportService _catalogImport;
|
||||
|
||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||
@@ -168,7 +181,8 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IDocumentationRepository documentation, IStudentRepository students,
|
||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates)
|
||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||
AiSettingsService aiSettings, AiPlanningService aiPlanning)
|
||||
{
|
||||
_subjects = subjects;
|
||||
_domainRepo = domainRepo;
|
||||
@@ -188,6 +202,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_periodSchedule = periodSchedule;
|
||||
_supervisionDuties = supervisionDuties;
|
||||
_letterTemplates = letterTemplates;
|
||||
_aiSettings = aiSettings;
|
||||
_aiPlanning = aiPlanning;
|
||||
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||||
LoadSubjects();
|
||||
LoadShorthandCodes();
|
||||
LoadGradingKeyTemplates();
|
||||
@@ -203,6 +220,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
LoadPeriodTimes();
|
||||
LoadSupervisionDuties();
|
||||
LoadLetterTemplates();
|
||||
LoadAiSettings();
|
||||
}
|
||||
|
||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||
@@ -292,6 +310,58 @@ public partial class SettingsViewModel : ObservableObject
|
||||
SupervisionDuties.Remove(item);
|
||||
}
|
||||
|
||||
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
|
||||
|
||||
private void LoadAiSettings()
|
||||
{
|
||||
AiEnabled = _aiSettings.Enabled;
|
||||
AiUsername = _aiSettings.Username;
|
||||
AiIsLoggedIn = _aiSettings.IsLoggedIn;
|
||||
if (AiIsLoggedIn) _ = RefreshAiBalance();
|
||||
}
|
||||
|
||||
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
|
||||
|
||||
private async Task RefreshAiBalance()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null) return;
|
||||
try
|
||||
{
|
||||
var balance = await _aiPlanning.GetBalanceAsync(token);
|
||||
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
|
||||
}
|
||||
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AiLogin()
|
||||
{
|
||||
AiLoginError = "";
|
||||
var valid = true;
|
||||
if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; }
|
||||
if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; }
|
||||
if (!valid) return;
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
|
||||
_aiSettings.SetCredentialsAndToken(AiUsername, token);
|
||||
AiPassword = "";
|
||||
AiIsLoggedIn = true;
|
||||
await RefreshAiBalance();
|
||||
}
|
||||
catch (AiBackendException ex) { AiLoginError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AiLogout()
|
||||
{
|
||||
_aiSettings.Logout();
|
||||
AiIsLoggedIn = false;
|
||||
AiBalanceDisplay = "";
|
||||
}
|
||||
|
||||
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||
|
||||
private void LoadPeriodTimes()
|
||||
@@ -697,46 +767,36 @@ public partial class SettingsViewModel : ObservableObject
|
||||
|
||||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
||||
|
||||
public void ImportCatalog(string json)
|
||||
public CompetencyCatalogImportPreview? PrepareCatalogImport(string json)
|
||||
{
|
||||
if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; }
|
||||
if (CatalogSubject is null)
|
||||
{
|
||||
CatalogValidation = "Bitte zuerst ein Fach auswählen.";
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var dto = JsonSerializer.Deserialize<CatalogDto>(json, opts);
|
||||
if (dto?.Domains is null) { CatalogValidation = "Ungültiges JSON-Format."; return; }
|
||||
|
||||
_domainRepo.DeleteBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel);
|
||||
|
||||
for (int i = 0; i < dto.Domains.Count; i++)
|
||||
{
|
||||
var d = dto.Domains[i];
|
||||
var domain = new CompetencyDomain
|
||||
{
|
||||
SubjectId = CatalogSubject.Id,
|
||||
GradeLevel = CatalogGradeLevel,
|
||||
Name = d.Name ?? "",
|
||||
Code = d.Code ?? "",
|
||||
SortOrder = i,
|
||||
Items = (d.Competencies ?? [])
|
||||
.Select((c, j) => new CompetencyItem
|
||||
{
|
||||
Code = c.Code ?? "",
|
||||
Description = c.Description ?? "",
|
||||
SortOrder = j,
|
||||
}).ToList(),
|
||||
};
|
||||
_domainRepo.Save(domain);
|
||||
}
|
||||
CatalogValidation = "";
|
||||
LoadCatalog();
|
||||
return _catalogImport.Analyze(
|
||||
json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel);
|
||||
}
|
||||
catch
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
CatalogValidation = "Import fehlgeschlagen – bitte JSON-Format prüfen.";
|
||||
CatalogValidation = ex.Message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCatalogImport(CompetencyCatalogImportPreview preview,
|
||||
CompetencyCatalogImportMode mode, IReadOnlySet<string> useImportedConflicts)
|
||||
{
|
||||
_catalogImport.Apply(preview, mode, useImportedConflicts);
|
||||
LoadCatalog();
|
||||
CatalogValidation = mode == CompetencyCatalogImportMode.Merge
|
||||
? "Kompetenzkatalog wurde sicher zusammengeführt."
|
||||
: "Kompetenzkatalog wurde vollständig ersetzt.";
|
||||
}
|
||||
|
||||
public string ExportCatalog()
|
||||
{
|
||||
var dto = new CatalogDto
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.AiAssistDialog"
|
||||
x:DataType="vm:AiAssistDialogViewModel"
|
||||
Title="KI-Unterstützung"
|
||||
Width="480" Height="560" MinWidth="420" MinHeight="420"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||
<ScrollViewer Grid.Row="0">
|
||||
<StackPanel Spacing="14" Margin="0,0,12,0">
|
||||
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !HasResults}">
|
||||
<TextBlock Text="Anweisung" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
|
||||
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
|
||||
IsEnabled="{Binding !IsBusy}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<StackPanel Spacing="10" IsVisible="{Binding HasResults}">
|
||||
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Summary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="Vorschläge (angehakt wird übernommen)" FontSize="12" Opacity="0.7"/>
|
||||
<ItemsControl ItemsSource="{Binding ReviewItems}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AiLessonReviewItem">
|
||||
<CheckBox Content="{Binding DisplayLabel}" IsChecked="{Binding Accepted}" Margin="0,3"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Die KI hat keine Stunden vorgeschlagen." Opacity="0.6" FontSize="12"
|
||||
IsVisible="{Binding !ReviewItems.Count}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Anfrage senden" HorizontalAlignment="Stretch" Click="OnSend"
|
||||
IsVisible="{Binding !HasResults}" IsEnabled="{Binding !IsBusy}"/>
|
||||
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"
|
||||
IsVisible="{Binding HasResults}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,31 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Groups;
|
||||
|
||||
public partial class AiAssistDialog : Window
|
||||
{
|
||||
public AiAssistDialog() => InitializeComponent();
|
||||
|
||||
private async void OnSend(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is AiAssistDialogViewModel vm && vm.SendCommand.CanExecute(null))
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private void OnApply(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
|
||||
{
|
||||
vm.ApplyCommand.Execute(null);
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object? s, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is AiAssistDialogViewModel vm) vm.CancelCommand.Execute(null);
|
||||
Close(false);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
|
||||
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public partial class PlanningTabView : UserControl
|
||||
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
||||
vm.OnShowLesson = ShowLessonViewerDialog;
|
||||
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||
vm.OnAiAssist = ShowAiAssistDialog;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,4 +148,20 @@ public partial class PlanningTabView : UserControl
|
||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess(result.Summary);
|
||||
return ok ? dialogVm.Result : null;
|
||||
}
|
||||
|
||||
private async Task<bool> ShowAiAssistDialog(Unit unit)
|
||||
{
|
||||
var dialogVm = new AiAssistDialogViewModel(
|
||||
App.Services.GetRequiredService<AiPlanningService>(),
|
||||
App.Services.GetRequiredService<AiSettingsService>(),
|
||||
App.Services.GetRequiredService<ILessonRepository>(),
|
||||
unit);
|
||||
|
||||
var dialog = new AiAssistDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
return dialogVm.Result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
|
||||
x:Class="LehrerApp.Desktop.Views.Settings.CompetencyCatalogImportDialog"
|
||||
x:DataType="vm:CompetencyCatalogImportViewModel"
|
||||
Title="Kompetenzkatalog importieren"
|
||||
Width="760" Height="720" MinWidth="620" MinHeight="560"
|
||||
CanResize="True" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
|
||||
<StackPanel Grid.Row="0" Spacing="10">
|
||||
<TextBlock Text="Import prüfen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="{Binding Target}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.7"/>
|
||||
|
||||
<StackPanel IsVisible="{Binding HasWarnings}" Spacing="4">
|
||||
<TextBlock Text="Hinweise zur Datei" FontWeight="SemiBold" Foreground="#B06A00"/>
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" TextWrapping="Wrap" FontSize="12" Foreground="#B06A00"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Importverfahren" FontSize="12" Opacity="0.7"/>
|
||||
<ComboBox ItemsSource="{Binding Modes}" SelectedItem="{Binding SelectedMode}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportModeOption">
|
||||
<TextBlock Text="{Binding Label}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<Border IsVisible="{Binding IsMerge}" Background="#143B82F6" CornerRadius="5" Padding="10,8">
|
||||
<TextBlock Text="Neue Einträge werden ergänzt. Bei Konflikten bleibt ohne abweichende Auswahl die vorhandene Fassung erhalten."
|
||||
TextWrapping="Wrap" FontSize="12"/>
|
||||
</Border>
|
||||
<Border IsVisible="{Binding IsReplace}" Background="#20C62828" CornerRadius="5" Padding="10,8">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Achtung: Alle vorhandenen Bereiche und Kompetenzen dieses Fachs und dieser Klassenstufe werden durch den Dateiinhalt ersetzt."
|
||||
TextWrapping="Wrap" Foreground="#C62828" FontWeight="SemiBold"/>
|
||||
<CheckBox Content="Ich möchte den vorhandenen Katalog vollständig ersetzen."
|
||||
IsChecked="{Binding ReplaceConfirmed}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,14,0,0" IsVisible="{Binding HasConflicts}">
|
||||
<TextBlock Grid.Row="0" Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CompetencyImportConflictItem">
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="12" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="{Binding Location}" FontWeight="SemiBold"/>
|
||||
<Grid ColumnDefinitions="110,*" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Vorhanden:" FontSize="12" Opacity="0.65"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ExistingValue}" TextWrapping="Wrap" FontSize="12"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Importiert:" FontSize="12" Opacity="0.65"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ImportedValue}" TextWrapping="Wrap" FontSize="12"/>
|
||||
</Grid>
|
||||
<CheckBox Content="Importierte Fassung übernehmen"
|
||||
IsChecked="{Binding UseImported}"
|
||||
IsVisible="{Binding $parent[Window].((vm:CompetencyCatalogImportViewModel)DataContext).IsMerge}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="10" Margin="0,14,0,0">
|
||||
<TextBlock Text="{Binding Error}" Foreground="Red" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Grid ColumnDefinitions="*,10,*">
|
||||
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||
<Button Grid.Column="2" Content="Import anwenden" HorizontalAlignment="Stretch" Click="OnApply"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
|
||||
namespace LehrerApp.Desktop.Views.Settings;
|
||||
|
||||
public partial class CompetencyCatalogImportDialog : Window
|
||||
{
|
||||
public CompetencyCatalogImportDialog() => InitializeComponent();
|
||||
|
||||
private void OnApply(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is CompetencyCatalogImportViewModel vm && vm.TryApply())
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -711,6 +711,43 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: KI-Unterstützung (4.5.9) -->
|
||||
<ContentPage Header="KI-Unterstützung">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
|
||||
|
||||
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
|
||||
|
||||
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding AiUsername}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
|
||||
<TextBlock FontSize="13" FontWeight="SemiBold">
|
||||
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
|
||||
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
</TabbedPage>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -64,7 +64,16 @@ public partial class SettingsView : UserControl
|
||||
|
||||
if (files.Count == 0) return;
|
||||
var json = await File.ReadAllTextAsync(files[0].Path.LocalPath);
|
||||
vm.ImportCatalog(json);
|
||||
var preview = vm.PrepareCatalogImport(json);
|
||||
if (preview is null) return;
|
||||
|
||||
var owner = topLevel as Window;
|
||||
if (owner is null) return;
|
||||
var dialog = new CompetencyCatalogImportDialog
|
||||
{
|
||||
DataContext = new CompetencyCatalogImportViewModel(vm, preview),
|
||||
};
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private async void OnExportClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
Reference in New Issue
Block a user