KI-Einstellungen um Feature- und Verbrauchsübersicht erweitern
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-22 11:40:19 +02:00
1 parent e2f019667a
commit 10921deb34
8 files changed
+454 -45

No files matched your search

@@ -0,0 +1,135 @@
using System.Net;
using System.Text;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed partial class SettingsViewModelTests
{
private const string UsageJson = """
{"apiVersion":"1","schema":"lehrerbackend.usage.v1",
"period":{"days":28,"from":"2026-09-24T12:00:00","to":"2026-10-22T12:00:00"},
"totals":{"calls":3,"chargedUsd":"0.300000","chargedEur":null},
"byAction":[
{"actionId":"plan","label":"Unterricht planen","operation":null,"calls":2,"chargedUsd":"0.200000","chargedEur":"0.180000"},
{"actionId":"worksheet_rewrite_openai","label":"Text überarbeiten","operation":"rewrite","calls":1,"chargedUsd":"0.100000","chargedEur":null}],
"entries":[{"at":"2026-10-22T11:00:00","actionId":"plan","label":"Unterricht planen","operation":null,"status":"settled","chargedUsd":"0.100000","chargedEur":"0.090000"}],
"entriesTruncated":true,"features":{"spellcheck":true}}
""";
private static AiPlanningService AiWithHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) =>
new(new HttpClient(new AccountHandler(handler)) { BaseAddress = new Uri("https://example.invalid/backend/") },
new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(),
new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), TestSupport.BuildAppLogger());
private static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK) =>
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
private sealed class AccountHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request);
}
[Fact]
public async Task AiAccount_AuthUndVerbrauchWerdenGeladenUndWaehrungenBleibenGetrennt()
{
var requests = new List<string>();
var ai = AiWithHandler(request =>
{
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
Assert.Equal("test-token", request.Headers.Authorization?.Parameter);
requests.Add(request.RequestUri!.PathAndQuery);
return Task.FromResult(JsonResponse(request.RequestUri.AbsolutePath.EndsWith("status.php")
? """{"balanceUsd":3.5,"balanceEur":3.1}""" : UsageJson));
});
var settings = TestSupport.BuildAiSettingsService();
var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai);
settings.SetCredentialsAndToken("test", "test-token");
await vm.RefreshAiAccountCommand.ExecuteAsync(null);
Assert.Equal(["/backend/status.php", "/backend/api/v1/usage.php?days=28"], requests);
Assert.Contains("3,50 USD", vm.AiBalanceDisplay);
Assert.Contains("3,10 €", vm.AiBalanceDisplay);
Assert.True(vm.AiHasUsage);
Assert.Equal(2, vm.AiUsageGroups.Count);
Assert.Equal(3, vm.AiUsageGroups.Sum(g => g.Calls));
Assert.Contains("0,30 USD", vm.AiUsageSummary);
Assert.DoesNotContain("€", vm.AiUsageSummary); // Euro-Gegenwert nicht für alle Aufrufe vorhanden
Assert.Equal("Unterricht planen", Assert.Single(vm.AiUsageEntries).Label);
Assert.Contains("neuesten 1", vm.AiUsageStatus);
Assert.Contains("2×", vm.AiFeatures.Single(f => f.ActionId == "plan").Usage);
Assert.Equal("Kostenlos", vm.AiFeatures.Single(f => f.ActionId == null).Usage);
Assert.False(vm.AiIsRefreshing);
}
[Fact]
public async Task AiAccount_AltesBackend_BehaeltGuthabenUndErklaertFehlendenVerbrauch()
{
var ai = AiWithHandler(request => Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("status.php")
? JsonResponse("""{"balanceUsd":2}""") : JsonResponse("{}", HttpStatusCode.NotFound)));
var settings = TestSupport.BuildAiSettingsService();
var vm = BuildViewModel(aiSettings: settings, aiPlanning: ai);
settings.SetCredentialsAndToken("test", "token");
await vm.RefreshAiAccountCommand.ExecuteAsync(null);
Assert.Equal("Guthaben: 2,00 USD", vm.AiBalanceDisplay);
Assert.Contains("noch keine Verbrauchsübersicht", vm.AiUsageStatus);
Assert.Empty(vm.AiUsageGroups);
Assert.False(vm.AiIsRefreshing);
}
[Fact]
public async Task AiAccount_AbmeldenWaerendLaden_VerhindertRueckkehrDerKontodaten()
{
var response = new TaskCompletionSource<HttpResponseMessage>();
var settings = TestSupport.BuildAiSettingsService();
var vm = BuildViewModel(aiSettings: settings, aiPlanning: AiWithHandler(_ => response.Task));
settings.SetCredentialsAndToken("test", "token");
var refresh = vm.RefreshAiAccountCommand.ExecuteAsync(null);
Assert.True(vm.AiIsRefreshing);
vm.AiLogoutCommand.Execute(null);
response.SetResult(JsonResponse("""{"balanceUsd":99}"""));
await refresh;
Assert.False(settings.IsLoggedIn);
Assert.False(vm.AiIsLoggedIn);
Assert.False(vm.AiIsRefreshing);
Assert.Empty(vm.AiBalanceDisplay);
Assert.Empty(vm.AiUsageEntries);
Assert.Empty(vm.AiUsageGroups);
Assert.Contains("Nach der Anmeldung", vm.AiUsageStatus);
}
[Theory]
[InlineData("{}")]
[InlineData("<html>Fehler</html>")]
[InlineData("null")]
public async Task AiAccount_UngueltigesGuthaben_LiefertVerstaendlichenFehler(string json)
{
var ai = AiWithHandler(_ => Task.FromResult(JsonResponse(json)));
var error = await Assert.ThrowsAsync<AiBackendException>(() => ai.GetAccountBalanceAsync("token"));
Assert.Contains("Guthaben", error.Message);
}
[Theory]
[InlineData("0.300000", "null")]
[InlineData("lehrerbackend.usage.v1", "unbekannt")]
[InlineData("2026-10-22T11:00:00", "ungueltig")]
public void AiUsage_UngueltigeAntwortWirdAbgelehnt(string oldValue, string newValue)
{
Assert.Throws<AiBackendException>(() => AiUsageReport.Parse(UsageJson.Replace(oldValue, newValue)));
}
[Fact]
public async Task AiAccount_NetzwerkfehlerUndAbgelaufeneAnmeldungWerdenErklaert()
{
var unavailable = AiWithHandler(_ => throw new HttpRequestException());
Assert.Contains("nicht erreichbar", (await Assert.ThrowsAsync<AiBackendException>(() => unavailable.GetUsageAsync("token"))).Message);
var expired = AiWithHandler(_ => Task.FromResult(JsonResponse("{}", HttpStatusCode.Unauthorized)));
Assert.Contains("Anmeldung abgelaufen", (await Assert.ThrowsAsync<AiBackendException>(() => expired.GetUsageAsync("token"))).Message);
}
}
@@ -11,7 +11,7 @@ using LehrerApp.Templating;
namespace LehrerApp.Desktop.Tests;
public sealed class SettingsViewModelTests
public sealed partial class SettingsViewModelTests
{
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null,
@@ -19,7 +19,7 @@ public sealed class SettingsViewModelTests
FakeCompetencyCatalogVersions? competencyVersions = null,
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
TrashViewModel? trashTab = null)
TrashViewModel? trashTab = null, AiSettingsService? aiSettings = null, AiPlanningService? aiPlanning = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -37,7 +37,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), aiSettings ?? TestSupport.BuildAiSettingsService(), aiPlanning ?? TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
@@ -0,0 +1,5 @@
using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.Services;
public sealed record AiAccountBalance([property: JsonRequired] decimal BalanceUsd, decimal? BalanceEur);
+38 -15
View File
@@ -146,25 +146,49 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
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);
public async Task<decimal> GetBalanceAsync(string token) =>
(await GetAccountBalanceAsync(token)).BalanceUsd;
public async Task<AiAccountBalance> GetAccountBalanceAsync(string token)
{
using var resp = await GetAccountResponseAsync("status.php", token);
try
{
return await resp.Content.ReadFromJsonAsync<AiAccountBalance>(JsonOptions)
?? throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden.");
}
catch (JsonException)
{
throw new AiBackendException("Das Guthaben des KI-Dienstes konnte nicht verarbeitet werden.");
}
}
public async Task<AiUsageReport> GetUsageAsync(string token)
{
using var resp = await GetAccountResponseAsync("api/v1/usage.php?days=28", token);
return AiUsageReport.Parse(await resp.Content.ReadAsStringAsync());
}
private async Task<HttpResponseMessage> GetAccountResponseAsync(string path, string token)
{
using var req = new HttpRequestMessage(HttpMethod.Get, path);
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte später erneut versuchen.");
}
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;
if (resp.IsSuccessStatusCode) return resp;
var status = resp.StatusCode;
resp.Dispose();
throw new AiBackendException(status switch
{
HttpStatusCode.Unauthorized => "Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.",
HttpStatusCode.NotFound when path.StartsWith("api/v1/usage") =>
"Dieses Backend stellt noch keine Verbrauchsübersicht bereit.",
_ => "Die Kontodaten des KI-Dienstes konnten nicht abgerufen werden."
});
}
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
@@ -695,7 +719,6 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult
{
public string? Error { get; set; }
@@ -0,0 +1,86 @@
using System.Globalization;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
/// <summary>Verbrauch einer Aktion im Zeitraum, z. B. „Text korrigieren“ oder eine Planung der LehrerApp (alle Apps am selben Backend).</summary>
/// <param name="Operation">Funktion der Arbeitsblatt-API; null bei Aktionen anderer Apps.</param>
/// <param name="ChargedEur">Nur, wenn für jeden Aufruf der Euro-Gegenwert dokumentiert ist; sonst null (der Dienst schätzt nichts).</param>
public sealed record AiUsageGroup(string ActionId, string Label, string? Operation, int Calls, decimal ChargedUsd, decimal? ChargedEur);
/// <summary>Ein berechneter Aufruf. <see cref="At"/> ist die Uhrzeit der Datenbank des Dienstes, ohne Zeitzone.</summary>
public sealed record AiUsageEntry(DateTime At, string ActionId, string Label, string? Operation, string Status, decimal ChargedUsd, decimal? ChargedEur);
/// <param name="SpellcheckGranted">Ob die Rechtschreibprüfung über das Backend freigegeben ist; null, wenn der Dienst es nicht meldet.</param>
public sealed record AiUsageReport(int Days, DateTime From, DateTime To, int Calls, decimal ChargedUsd, decimal? ChargedEur,
IReadOnlyList<AiUsageGroup> ByAction, IReadOnlyList<AiUsageEntry> Entries, bool EntriesTruncated, bool? SpellcheckGranted)
{
/// <summary>
/// Liest die Antwort von <c>api/v1/usage.php</c>. Unbekannte zusätzliche Felder werden übergangen (der Dienst darf wachsen), fehlende oder
/// unplausible Pflichtfelder sind ein Fehler.
/// </summary>
public static AiUsageReport Parse(string json)
{
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
var root = document.RootElement;
Need(root.GetProperty("apiVersion").GetString() == "1" && root.GetProperty("schema").GetString() == "lehrerbackend.usage.v1");
var period = root.GetProperty("period");
var days = period.GetProperty("days").GetInt32();
var totals = root.GetProperty("totals");
var groups = new List<AiUsageGroup>();
foreach (var item in root.GetProperty("byAction").EnumerateArray())
{
Need(groups.Count < 256);
groups.Add(new(Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), item.GetProperty("calls").GetInt32(),
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
}
var entries = new List<AiUsageEntry>();
foreach (var item in root.GetProperty("entries").EnumerateArray())
{
Need(entries.Count < 1000);
entries.Add(new(Date(item.GetProperty("at").GetString()), Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), Text(item, "status", 32),
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
}
bool? spellcheck = root.TryGetProperty("features", out var features) && features.TryGetProperty("spellcheck", out var flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False
? flag.GetBoolean() : null;
var calls = totals.GetProperty("calls").GetInt32();
Need(days is >= 1 and <= 366 && calls >= 0 && groups.All(g => g.Calls >= 0 && g.ChargedUsd >= 0 && g.ChargedEur is null or >= 0) && entries.All(e => e.ChargedUsd >= 0));
return new(days, Date(period.GetProperty("from").GetString()), Date(period.GetProperty("to").GetString()), calls, RequiredMoney(totals, "chargedUsd"), Money(totals, "chargedEur"),
groups, entries, root.GetProperty("entriesTruncated").GetBoolean(), spellcheck);
}
catch (Exception e) when (e is JsonException or InvalidOperationException or KeyNotFoundException or FormatException or ArgumentException or OverflowException)
{ throw new AiBackendException("Die Verbrauchsdaten des KI-Dienstes konnten nicht verarbeitet werden."); }
}
private static void Need(bool condition) { if (!condition) throw new FormatException(); }
private static string Text(JsonElement item, string name, int max)
{
var value = item.GetProperty(name).GetString();
Need(!string.IsNullOrWhiteSpace(value) && value.Length <= max);
return value!;
}
private static string? Operation(JsonElement item) =>
item.TryGetProperty("operation", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static decimal RequiredMoney(JsonElement item, string name) =>
Money(item, name) ?? throw new FormatException();
private static decimal? Money(JsonElement item, string name)
{
if (!item.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null;
// Dezimaltext mit höchstens sechs Nachkommastellen wie im ganzen Vertrag, nie eine Fließkommazahl
var text = value.GetString();
Need(text is not null && System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{1,9}(\.\d{1,6})?$"));
return decimal.Parse(text!, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
}
private static DateTime Date(string? text)
{
Need(DateTime.TryParseExact(text, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _));
return DateTime.ParseExact(text!, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
}
}
@@ -1,54 +1,115 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
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 LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.ViewModels.Settings;
public partial class SettingsViewModel
{
// ── KI-Unterstützung (4.5.9) ──────────────────────────────────────────────
[ObservableProperty] private bool _aiEnabled;
[ObservableProperty] private bool _aiEnabled;
[ObservableProperty] private string _aiUsername = "";
[ObservableProperty] private string _aiPassword = "";
[ObservableProperty] private string _aiLoginError = "";
[ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = "";
[ObservableProperty] private bool _aiIsRefreshing;
[ObservableProperty] private string _aiAccountStatus = "";
[ObservableProperty] private string _aiUsageSummary = "";
[ObservableProperty] private string _aiUsageStatus = "Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen.";
[ObservableProperty] private string _aiUsagePeriod = "";
[ObservableProperty] private bool _aiHasUsage;
private int _aiRefreshVersion;
// ── KI-Unterstützung: Laden / Anmelden / Abmelden ────────────────────────
public ObservableCollection<AiFeatureItem> AiFeatures { get; } =
[
new("plan", "Unterricht planen", "Einheiten und einzelne Stunden entwerfen oder überarbeiten; Vorschläge vor der Übernahme prüfen."),
new("explain", "Didaktik erklären", "Didaktische Erläuterungen zu einer vorgeschlagenen Unterrichtsstunde abrufen."),
new("gbu", "Gefährdungsbeurteilung", "Einen GBU-Entwurf zum geplanten Experiment erstellen und im Assistenten weiterbearbeiten."),
new("untis-status", "Fehlzeiten prüfen", "Vorschläge für den Entschuldigungsstatus beim WebUntis-Abgleich erhalten."),
new(null, "Stoffdaten nachschlagen", "Stoffe im GBU-Assistenten in der RiSU-Stoffliste suchen. Keine KI, kein Guthabenverbrauch.", "Kostenlos")
];
public ObservableCollection<AiUsageGroupItem> AiUsageGroups { get; } = [];
public ObservableCollection<AiUsageEntryItem> AiUsageEntries { get; } = [];
private void LoadAiSettings()
{
AiEnabled = _aiSettings.Enabled;
AiUsername = _aiSettings.Username;
AiIsLoggedIn = _aiSettings.IsLoggedIn;
if (AiIsLoggedIn) _ = RefreshAiBalance();
if (AiIsLoggedIn) _ = RefreshAiAccount();
}
partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value);
private async Task RefreshAiBalance()
[RelayCommand]
private async Task RefreshAiAccount()
{
var token = _aiSettings.GetToken();
if (token is null) return;
var version = ++_aiRefreshVersion;
bool Current() => version == _aiRefreshVersion && token == _aiSettings.GetToken();
AiIsRefreshing = true;
AiAccountStatus = "";
AiBalanceDisplay = "Guthaben wird geladen …";
ClearAiUsage("Verbrauch wird geladen …");
try
{
var balance = await _aiPlanning.GetBalanceAsync(token);
AiBalanceDisplay = $"Guthaben: {balance:0.00} €";
// Die Übersicht bleibt nutzbar, wenn ein älteres Backend nur das Guthaben liefert.
try
{
var balance = await _aiPlanning.GetAccountBalanceAsync(token);
if (!Current()) return;
AiBalanceDisplay = "Guthaben: " + AiMoney.Format(balance.BalanceUsd, balance.BalanceEur);
}
catch (AiBackendException ex)
{
if (!Current()) return;
AiBalanceDisplay = "Guthaben nicht verfügbar";
AiAccountStatus = ex.Message;
}
try
{
var usage = await _aiPlanning.GetUsageAsync(token);
if (!Current()) return;
AiUsageStatus = usage.Calls == 0 ? $"In den letzten {usage.Days} Tagen wurde nichts berechnet." : "";
AiUsagePeriod = $"{usage.From:dd.MM.yyyy HH:mm} bis {usage.To:dd.MM.yyyy HH:mm} (Backend-Zeit)";
AiUsageSummary = $"Gesamt: {AiMoney.Format(usage.ChargedUsd, usage.ChargedEur)} · {usage.Calls} Aufrufe";
foreach (var group in usage.ByAction.OrderByDescending(g => g.ChargedUsd))
AiUsageGroups.Add(new(group.Label, group.Calls, AiMoney.Format(group.ChargedUsd, group.ChargedEur),
usage.ChargedUsd > 0 ? (double)(100 * group.ChargedUsd / usage.ChargedUsd) : 0));
foreach (var entry in usage.Entries.OrderByDescending(e => e.At))
AiUsageEntries.Add(new(entry.At.ToString("dd.MM.yyyy HH:mm"), entry.Label,
AiMoney.Format(entry.ChargedUsd, entry.ChargedEur)));
foreach (var feature in AiFeatures.Where(f => f.ActionId is not null))
{
var groups = usage.ByAction.Where(g => g.ActionId == feature.ActionId).ToArray();
feature.Usage = groups.Length == 0 ? "Keine berechneten Aufrufe" :
$"{groups.Sum(g => g.Calls)}× · {AiMoney.Format(groups.Sum(g => g.ChargedUsd), groups.All(g => g.ChargedEur.HasValue) ? groups.Sum(g => g.ChargedEur!.Value) : null)}";
}
AiHasUsage = usage.Calls > 0;
if (usage.EntriesTruncated)
AiUsageStatus = $"Die Liste zeigt die neuesten {usage.Entries.Count} Aufrufe. Die Summen enthalten alle Aufrufe im Zeitraum.";
}
catch (AiBackendException ex)
{
if (Current()) AiUsageStatus = ex.Message;
}
}
catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; }
finally { if (Current()) AiIsRefreshing = false; }
}
private void ClearAiUsage(string status)
{
AiUsageGroups.Clear();
AiUsageEntries.Clear();
AiHasUsage = false;
AiUsageSummary = "";
AiUsagePeriod = "";
AiUsageStatus = status;
foreach (var feature in AiFeatures.Where(f => f.ActionId is not null)) feature.Usage = "Verbrauch nicht verfügbar";
}
[RelayCommand]
@@ -64,18 +125,45 @@ public partial class SettingsViewModel
{
var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword);
_aiSettings.SetCredentialsAndToken(AiUsername, token);
AiPassword = "";
AiIsLoggedIn = true;
await RefreshAiBalance();
await RefreshAiAccount();
}
catch (AiBackendException ex) { AiLoginError = ex.Message; }
finally { AiPassword = ""; }
}
[RelayCommand]
private void AiLogout()
{
++_aiRefreshVersion;
_aiSettings.Logout();
AiIsLoggedIn = false;
AiIsRefreshing = false;
AiBalanceDisplay = "";
AiAccountStatus = "";
AiLoginError = "";
AiPassword = "";
ClearAiUsage("Nach der Anmeldung erscheint hier der Verbrauch der letzten vier Wochen.");
}
}
public partial class AiFeatureItem(string? actionId, string name, string description, string usage = "Verbrauch nach Anmeldung") : ObservableObject
{
public string? ActionId { get; } = actionId;
public string Name { get; } = name;
public string Description { get; } = description;
[ObservableProperty] private string _usage = usage;
}
public sealed record AiUsageGroupItem(string Label, int Calls, string Amount, double Percent)
{
public string CallsDisplay => $"{Calls}×";
}
public sealed record AiUsageEntryItem(string When, string Label, string Amount);
internal static class AiMoney
{
public static string Format(decimal usd, decimal? eur) => Number(usd) + " USD" +
(eur is { } value ? $" (≈ {Number(value)} €)" : "");
private static string Number(decimal value) => value.ToString(value != 0 && Math.Abs(value) < 0.1m ? "0.000000" : "0.00", CultureInfo.GetCultureInfo("de-DE"));
}
@@ -1063,15 +1063,15 @@
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="820">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="KI-Zugang und Verbrauch" 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."/>
Text="KI-Anfragen werden über deinen Zugang am gemeinsamen Backend abgerechnet. Ein Klick auf die jeweilige KI-Aktion startet die Anfrage und kann Guthaben verbrauchen; es gibt keine zusätzliche Kostenbestätigung. Vorschläge prüfst du vor der Übernahme."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="8" MaxWidth="420" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
@@ -1090,9 +1090,71 @@
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Aktualisieren" Command="{Binding RefreshAiAccountCommand}" IsEnabled="{Binding !AiIsRefreshing}"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}"/>
</StackPanel>
</StackPanel>
<ProgressBar IsIndeterminate="True" IsVisible="{Binding AiIsRefreshing}" Height="3"/>
<TextBlock Text="{Binding AiAccountStatus}" TextWrapping="Wrap" FontSize="12"
IsVisible="{Binding AiAccountStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="Funktionen in der LehrerApp" FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"/>
<TextBlock Text="Die Übersicht zeigt die eingebauten Funktionen und ihren Verbrauch in den letzten vier Wochen. Verfügbarkeit und Abrechnung hängen vom Backend und deinem Guthaben ab. Die Stoffsuche nutzt den angemeldeten Zugang auch ohne KI."
TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding AiFeatures}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AiFeatureItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1" Padding="0,10">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="12" Opacity="0.8"/>
<TextBlock Text="{Binding Usage}" TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Verbrauch der letzten vier Wochen" FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"/>
<TextBlock Text="{Binding AiUsagePeriod}" FontSize="12" Opacity="0.7" TextWrapping="Wrap"/>
<TextBlock Text="{Binding AiUsageSummary}" FontWeight="SemiBold" TextWrapping="Wrap" IsVisible="{Binding AiIsLoggedIn}"/>
<TextBlock Text="{Binding AiUsageStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding AiUsageStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding AiUsageGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AiUsageGroupItem">
<StackPanel Spacing="5" Margin="0,0,0,12">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" Margin="0,0,12,0"/>
<TextBlock Grid.Column="1" Text="{Binding CallsDisplay}" Opacity="0.7" Margin="0,0,12,0"/>
<TextBlock Grid.Column="2" Text="{Binding Amount}"/>
</Grid>
<ProgressBar Value="{Binding Percent}" Maximum="100" Height="5"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Einzelne Aufrufe (neueste zuerst)" FontWeight="SemiBold" IsVisible="{Binding AiHasUsage}"/>
<ScrollViewer MaxHeight="280" HorizontalScrollBarVisibility="Disabled" IsVisible="{Binding AiHasUsage}">
<ItemsControl ItemsSource="{Binding AiUsageEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AiUsageEntryItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" Margin="0,0,12,0"/>
<TextBlock Grid.Row="1" Text="{Binding When}" FontSize="11" Opacity="0.65"/>
<TextBlock Grid.Column="1" Grid.RowSpan="2" Text="{Binding Amount}" FontSize="12" VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Text="Enthalten sind berechnete Aufrufe aller Apps mit diesem Zugang, auch des ArbeitsblattDesigners. Kostenlose Anfragen erscheinen nicht. Zeiten entsprechen der Backend-Uhr. Euro-Werte erscheinen nur, wenn sie vom Backend mitgeliefert werden."
IsVisible="{Binding AiIsLoggedIn}" TextWrapping="Wrap" FontSize="12" Opacity="0.65"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
+10
View File
@@ -2601,6 +2601,16 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
könnte eine halluzinierte Id im schlimmsten Fall eine fremde Lesson überschreiben), sondern
immer als neue Lesson mit frischer Id behandelt — mit dediziertem Test abgesichert.
**Erweiterung 2026-10-22:** Unter dem Zugang stehen eine Featureübersicht der LehrerApp
(einschließlich kostenloser RiSU-Stoffsuche), Verbrauch nach Aktion mit Anteilsbalken und
einzelne berechnete Aufrufe der letzten 28 Tage. Gemeinsamer Zugang: Auch Verbrauch anderer
Apps wird über `api/v1/usage.php` angezeigt. Aktualisieren lädt Guthaben und Verbrauch neu;
ältere Backends ohne Verbrauchsendpunkt bleiben nutzbar. USD werden korrekt als USD
angezeigt, Euro nur bei mitgeliefertem Gegenwert. Keine zusätzlichen Kostenbestätigungen;
der Kostenhinweis steht zentral in den Einstellungen, die Übernahme von Vorschlägen bleibt
eine eigene bewusste Aktion. Die Übersicht behauptet keine Backend-Freigaben: Die bisherige
Capabilities-Schnittstelle gilt nur für Arbeitsblatt-Funktionen.
Neue Einstellungen-Tab "KI-Unterstützung": Aktivieren-Schalter, Benutzername/Passwort →
Anmelden tauscht das Passwort gegen ein Bearer-Token (nie das Passwort selbst persistiert).
Das Token liegt lokal AES-256-verschlüsselt über das bestehende `SyncCrypto` (gleiches