feat: add AI administration tab with provider dialog, test and usage overview

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 11:03:28 +02:00
1 parent 1da4423c52
commit 65993c268b
5 files changed
+426

No files matched your search

@@ -0,0 +1,175 @@
@using FamilyPlanner.Domain.Ai
@using FamilyPlanner.Shared.Ai
@inject IAiApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">KI-Anbieter</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditAsync(null)" Class="fp-add-ai">Anbieter hinzufügen</MudButton>
</div>
<MudText Typo="Typo.body2" Class="mb-3">
Die KI erzeugt Aufgaben und prüft Lösungen. Cloud-Anbieter erhalten nur Texte; Fotos gehen nur an Anbieter im Heimnetz oder mit ausdrücklicher Freigabe.
Nur Eltern können KI-Funktionen auslösen.
</MudText>
@if (_providers is null)
{
<LoadingIndicator />
}
else if (_providers.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="fp-ai-empty">Noch kein Anbieter eingerichtet. Ohne Anbieter sind alle KI-Funktionen ausgeblendet.</MudAlert>
}
else
{
<MudTable T="AiProviderDto" Items="_providers" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Class="fp-ai-table">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Modell</MudTh>
<MudTh>Einsatz</MudTh>
<MudTh>Datenschutz</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
<span class="fp-ai-name">@context.Name</span>
<MudText Typo="Typo.caption">@AiRules.Info(context.Type).DisplayName · @context.BaseUrl</MudText>
@if (!context.IsActive)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="fp-ai-inactive">inaktiv</MudChip>
}
</MudTd>
<MudTd DataLabel="Modell">@context.Model</MudTd>
<MudTd DataLabel="Einsatz">@DescribePurposes(context.Purposes)</MudTd>
<MudTd DataLabel="Datenschutz">
@if (context.IsHomeNetwork)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Outlined" Class="fp-ai-home">Heimnetz</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Outlined" Class="fp-ai-cloud">Cloud</MudChip>
}
<MudText Typo="Typo.caption">
Bilder @(context.IsHomeNetwork || context.AllowImages ? "erlaubt" : "gesperrt") · @(context.HasApiKey ? "Schlüssel gesetzt" : "kein Schlüssel") ·
@(context.DailyLimitPerUser == 0 ? "ohne Tageslimit" : $"{context.DailyLimitPerUser}/Tag")
</MudText>
</MudTd>
<MudTd Style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="@(_testing == context.Id)"
OnClick="() => TestAsync(context)" Class="fp-ai-test">Testen</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditAsync(context)" aria-label="@($"{context.Name} bearbeiten")" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(context)" aria-label="@($"{context.Name} löschen")" />
</MudTd>
</RowTemplate>
</MudTable>
}
@if (_testResult is { } test)
{
<MudAlert Severity="@(test.Result.Success ? Severity.Success : Severity.Error)" Class="mt-3 fp-ai-testresult" role="status">
<strong>@test.Name:</strong> @test.Result.Message
@if (test.Result.Success)
{
<span> (@test.Result.DurationMs ms, Antwort: „@test.Result.Reply“)</span>
}
</MudAlert>
}
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mt-6 mb-1">Nutzung</MudText>
@if (_usage is null)
{
<LoadingIndicator />
}
else
{
<MudText Typo="Typo.body2" Class="mb-2 fp-ai-today">Heute: @_usage.CallsToday Aufrufe@(_usage.FailedToday > 0 ? $", davon {_usage.FailedToday} fehlgeschlagen" : string.Empty).</MudText>
@if (_usage.Recent.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-ai-nousage">Noch keine KI-Aufrufe.</MudText>
}
else
{
<MudTable T="AiUsageDto" Items="_usage.Recent" Breakpoint="Breakpoint.Sm" Elevation="0" Outlined="true" Dense="true" Class="fp-ai-usage">
<HeaderContent>
<MudTh>Zeit</MudTh>
<MudTh>Person</MudTh>
<MudTh>Anbieter</MudTh>
<MudTh>Zweck</MudTh>
<MudTh>Ergebnis</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Zeit">@UserManagement.FormatDate(context.At)</MudTd>
<MudTd DataLabel="Person">@context.UserName</MudTd>
<MudTd DataLabel="Anbieter">@context.ProviderName (@context.Model)</MudTd>
<MudTd DataLabel="Zweck">@AiRules.PurposeName(context.Purpose)@(context.HadImages ? " · mit Bild" : string.Empty)</MudTd>
<MudTd DataLabel="Ergebnis">
@if (context.Success)
{
<span>OK · @context.DurationMs ms@(context.InputTokens is { } i ? $" · {i}→{context.OutputTokens} Tokens" : string.Empty)</span>
}
else
{
<span class="fp-ai-failed">Fehler: @context.Error</span>
}
</MudTd>
</RowTemplate>
</MudTable>
}
}
@code {
private sealed record TestOutcome(string Name, AiTestResultDto Result);
private List<AiProviderDto>? _providers;
private AiUsageOverviewDto? _usage;
private TestOutcome? _testResult;
private Guid? _testing;
protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync()
{
_providers = (await Api.GetProvidersAsync()).Value ?? [];
_usage = (await Api.GetUsageAsync()).Value;
}
private static string DescribePurposes(AiPurpose purposes) =>
purposes == AiPurpose.None ? "–" : string.Join(", ", AiRules.SelectablePurposes.Where(p => purposes.HasFlag(p)).Select(AiRules.PurposeName));
private async Task EditAsync(AiProviderDto? provider)
{
var dialog = await DialogService.ShowAsync<AiProviderDialog>(provider is null ? "KI-Anbieter hinzufügen" : $"{provider.Name} bearbeiten",
new DialogParameters<AiProviderDialog> { { d => d.Provider, provider } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
Snackbar.Add("Anbieter gespeichert.", Severity.Success);
await LoadAsync();
}
}
private async Task DeleteAsync(AiProviderDto provider)
{
if (await DialogService.ShowMessageBoxAsync("Anbieter löschen?", $"„{provider.Name}“ und der gespeicherte Schlüssel werden entfernt.",
yesText: "Löschen", cancelText: "Abbrechen") != true)
{
return;
}
var result = await Api.DeleteProviderAsync(provider.Id);
Snackbar.Add(result.Problem?.Message ?? "Anbieter gelöscht.", result.Succeeded ? Severity.Success : Severity.Error);
await LoadAsync();
}
private async Task TestAsync(AiProviderDto provider)
{
_testing = provider.Id;
var result = await Api.TestProviderAsync(provider.Id);
_testing = null;
_testResult = new TestOutcome(provider.Name, result.Value ?? new AiTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", 0, null));
_usage = (await Api.GetUsageAsync()).Value ?? _usage;
}
}
@@ -0,0 +1,185 @@
@using FamilyPlanner.Domain.Ai
@using FamilyPlanner.Shared.Ai
@inject IAiApi Api
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="ai-provider" id="ai-provider-form">
<MudStack Spacing="3">
@if (_generalError is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_generalError</MudAlert>
}
<MudTextField @bind-Value="_name" Label="Name" InputId="ai-name" Variant="Variant.Outlined" AutoFocus="true"
Placeholder="z. B. Ollama im Keller" Error="@HasError("Name")" ErrorText="@ErrorFor("Name")" />
<MudSelect T="AiProviderType" Value="_type" ValueChanged="OnTypeChanged" Label="Anbieter" Variant="Variant.Outlined" Class="fp-ai-type"
HelperText="@TypeInfo.Hint">
@foreach (var info in AiRules.Types)
{
<MudSelectItem T="AiProviderType" Value="@info.Type">@info.DisplayName</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_baseUrl" Label="Adresse" InputId="ai-url" Variant="Variant.Outlined" Placeholder="@TypeInfo.DefaultBaseUrl"
HelperText="@UrlHint" Error="@HasError("BaseUrl")" ErrorText="@ErrorFor("BaseUrl")" />
<MudTextField @bind-Value="_model" Label="Modell" InputId="ai-model" Variant="Variant.Outlined" Placeholder="@TypeInfo.DefaultModel"
Error="@HasError("Model")" ErrorText="@ErrorFor("Model")" />
<MudTextField @bind-Value="_apiKey" Label="@(Provider?.HasApiKey == true ? "API-Schlüssel (leer lassen = unverändert)" : "API-Schlüssel")"
InputId="ai-key" Variant="Variant.Outlined" InputType="InputType.Password" Disabled="_clearKey" autocomplete="off"
HelperText="Der Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt."
Error="@HasError("ApiKey")" ErrorText="@ErrorFor("ApiKey")" />
@if (Provider?.HasApiKey == true)
{
<MudCheckBox T="bool" @bind-Value="_clearKey" Label="Gespeicherten Schlüssel entfernen" Class="fp-ai-clearkey" />
}
<MudStack Row="true" Spacing="3">
<MudNumericField T="int" @bind-Value="_timeout" Label="Zeitlimit (Sekunden)" InputId="ai-timeout" Variant="Variant.Outlined" Min="@AiRules.MinTimeoutSeconds"
Max="@AiRules.MaxTimeoutSeconds" Error="@HasError("TimeoutSeconds")" ErrorText="@ErrorFor("TimeoutSeconds")" />
<MudNumericField T="int" @bind-Value="_limit" Label="Tageslimit je Person" InputId="ai-limit" Variant="Variant.Outlined" Min="0" Max="@AiRules.MaxDailyLimit"
HelperText="0 = unbegrenzt" Error="@HasError("DailyLimitPerUser")" ErrorText="@ErrorFor("DailyLimitPerUser")" />
</MudStack>
<MudSwitch T="bool" @bind-Value="_active" Label="Aktiv" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_allowImages" Label="Bilder dürfen an diesen Anbieter gesendet werden" Color="Color.Warning" Class="fp-ai-images" />
@if (!IsHomeNetwork)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-ai-cloudwarning">
Diese Adresse liegt außerhalb des Heimnetzes: Aufgaben und – nur wenn oben erlaubt – Fotos von Heften verlassen das Heimnetz.
</MudAlert>
}
else
{
<MudText Typo="Typo.caption">Heimnetz-Adresse: Bilder bleiben im Heimnetz, die Freigabe ist nicht nötig.</MudText>
}
<div>
<MudText Typo="Typo.subtitle2" Class="mb-1">Einsatzzweck</MudText>
<MudText Typo="Typo.caption" Class="mb-1">Je Zweck genau ein Anbieter. Ohne eigene Wahl gilt der Standard-Anbieter (außer bei der Foto-Prüfung).</MudText>
@foreach (var purpose in AiRules.SelectablePurposes)
{
var current = purpose;
<MudCheckBox T="bool" Value="_purposes.HasFlag(current)" ValueChanged="on => SetPurpose(current, on)"
Label="@AiRules.PurposeName(current)" Class="@($"fp-ai-purpose fp-ai-purpose-{(int)current}")" />
}
@if (HasError("Purposes"))
{
<MudText Typo="Typo.caption" Color="Color.Error" role="alert">@ErrorFor("Purposes")</MudText>
}
</div>
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton ButtonType="ButtonType.Submit" form="ai-provider-form" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-dialog-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter]
public AiProviderDto? Provider { get; set; }
private string _name = string.Empty;
private AiProviderType _type = AiProviderType.Ollama;
private string? _baseUrl;
private string _model = string.Empty;
private string? _apiKey;
private bool _clearKey;
private int _timeout = AiRules.DefaultTimeoutSeconds;
private int _limit = AiRules.DefaultDailyLimit;
private bool _active = true;
private bool _allowImages;
private AiPurpose _purposes;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _generalError;
private bool _busy;
private AiProviderTypeInfo TypeInfo => AiRules.Info(_type);
private bool IsHomeNetwork => AiRules.NormalizeBaseUrl(_type, _baseUrl).Url is { } url && AiRules.IsHomeNetwork(url);
private string UrlHint => _type == AiProviderType.OpenAiCompatible
? "Basisadresse ohne /chat/completions, z. B. https://api.perplexity.ai"
: "Leer lassen für die Voreinstellung.";
protected override void OnInitialized()
{
if (Provider is not { } p)
{
_model = TypeInfo.DefaultModel;
return;
}
(_name, _type, _baseUrl, _model, _timeout, _limit, _active, _allowImages, _purposes) =
(p.Name, p.Type, p.BaseUrl, p.Model, p.TimeoutSeconds, p.DailyLimitPerUser, p.IsActive, p.AllowImages, p.Purposes);
}
private void OnTypeChanged(AiProviderType type)
{
// Voreinstellungen nur übernehmen, solange der Benutzer nichts Eigenes eingetragen hat.
var oldInfo = TypeInfo;
_type = type;
if (string.IsNullOrWhiteSpace(_model) || _model == oldInfo.DefaultModel)
{
_model = TypeInfo.DefaultModel;
}
if (string.IsNullOrWhiteSpace(_baseUrl) || _baseUrl == oldInfo.DefaultBaseUrl)
{
_baseUrl = null;
}
}
private void SetPurpose(AiPurpose purpose, bool on) => _purposes = on ? _purposes | purpose : _purposes & ~purpose;
private async Task SaveAsync()
{
_generalError = null;
var request = new SaveAiProviderRequest(
_name.Trim(), _type, string.IsNullOrWhiteSpace(_baseUrl) ? null : _baseUrl.Trim(), _model.Trim(),
string.IsNullOrEmpty(_apiKey) ? null : _apiKey.Trim(), _clearKey, _timeout, _active, _allowImages, _purposes, _limit, Provider?.Version);
var validation = new SaveAiProviderRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
if (!validation.IsValid)
{
return;
}
_busy = true;
var result = Provider is null ? await Api.CreateProviderAsync(request) : await Api.UpdateProviderAsync(Provider.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_errors = (problem.Errors ?? [])
.Where(e => e.Key.Length > 0)
.ToDictionary(e => e.Key, e => string.Join(" ", e.Value), StringComparer.OrdinalIgnoreCase);
if (_errors.Count == 0)
{
_generalError = problem.Message;
}
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private void Cancel() => Dialog?.Cancel();
}
@@ -18,4 +18,7 @@
<MudTabPanel Text="Kalender" Icon="@Icons.Material.Filled.EditCalendar">
<CalendarManagement />
</MudTabPanel>
<MudTabPanel Text="KI" Icon="@Icons.Material.Filled.AutoAwesome">
<AiManagement />
</MudTabPanel>
</MudTabs>
+1
View File
@@ -33,6 +33,7 @@ builder.Services.AddScoped<ITasksApi, TasksApi>();
builder.Services.AddScoped<ICalendarApi, CalendarApi>();
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IAiApi, AiApi>();
builder.Services.AddScoped<IPushService, PushService>();
builder.Services.AddScoped<ILocalStore, BrowserLocalStore>();
builder.Services.AddScoped<ILiveConnection, LiveConnection>();
@@ -0,0 +1,62 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Ai;
namespace FamilyPlanner.Web.Client.Services;
public interface IAiApi
{
Task<ApiResult<List<AiProviderDto>>> GetProvidersAsync(CancellationToken ct = default);
Task<ApiResult<AiProviderDto>> CreateProviderAsync(SaveAiProviderRequest request, CancellationToken ct = default);
/// <summary>Bei 409 (veraltete Version) enthält <see cref="ApiResult{T}.Problem"/> die Meldung des Servers.</summary>
Task<ApiResult<AiProviderDto>> UpdateProviderAsync(Guid id, SaveAiProviderRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteProviderAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<AiTestResultDto>> TestProviderAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default);
Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default);
}
public sealed class AiApi(HttpClient http) : IAiApi
{
public Task<ApiResult<List<AiProviderDto>>> GetProvidersAsync(CancellationToken ct = default) =>
Send<List<AiProviderDto>>(() => http.GetAsync(AiApiRoutes.Providers, ct), ct);
public Task<ApiResult<AiProviderDto>> CreateProviderAsync(SaveAiProviderRequest request, CancellationToken ct = default) =>
Send<AiProviderDto>(() => http.PostAsJsonAsync(AiApiRoutes.Providers, request, ct), ct);
public Task<ApiResult<AiProviderDto>> UpdateProviderAsync(Guid id, SaveAiProviderRequest request, CancellationToken ct = default) =>
Send<AiProviderDto>(() => http.PutAsJsonAsync($"{AiApiRoutes.Providers}/{id}", request, ct), ct);
public Task<ApiResult<bool>> DeleteProviderAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{AiApiRoutes.Providers}/{id}", ct), ct);
public Task<ApiResult<AiTestResultDto>> TestProviderAsync(Guid id, CancellationToken ct = default) =>
Send<AiTestResultDto>(() => http.PostAsync($"{AiApiRoutes.Providers}/{id}/test", null, ct), ct);
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) =>
Send<AiUsageOverviewDto>(() => http.GetAsync(AiApiRoutes.Usage, ct), ct);
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) =>
Send<AiStatusDto>(() => http.GetAsync(AiApiRoutes.Status, ct), ct);
/// <summary>Wie <see cref="ApiCall"/>, aber 409 liefert die Fehlermeldung des Servers (kein aktueller Stand im Body).</summary>
private static async Task<ApiResult<T>> Send<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
try
{
using var response = await send();
return response.IsSuccessStatusCode
? ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct))
: ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(ApiProblem.Offline);
}
}
}