feat: Google AI provider, provider fallback with notice, model lists, adult AI chat settings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f11f12c90
commit
0c886652cf
28 files changed
+3723
-25
No files matched your search
@@ -69,3 +69,9 @@ Copilot ist ausdrücklich ein Wunsch, hat aber keine offene API für Dritt-Apps
|
||||
|
||||
## Offene Fragen
|
||||
Modell-Empfehlungen je Anbieter (Rechnen/Lesen von Handschrift), Umgang mit Anbieter-Ausfall (Fallback-Anbieter?), Streaming (nicht nötig, solange Aufgaben als Ganzes erzeugt werden).
|
||||
|
||||
## Nachtrag: Anbieter-Fallback, Google AI, Modellliste, KI-Chat der Erwachsenen
|
||||
* **Google AI (Gemini)** ist ein eigener Anbietertyp (`AiProviderType.Google`, `generateContent`, Schlüssel im Header `x-goog-api-key`, nur https, keine Web-Suche).
|
||||
* **Fallback:** `AiService.RunAsync` befragt bei Ausfall (nicht erreichbar, Zeitlimit, Fehler, Limit, unbrauchbare Antwort) die übrigen aktiven Anbieter – zuerst die dem Zweck zugeordneten, dann „Allgemein“, dann nach Name. Bei Fotos/Web-Suche nur Anbieter mit entsprechender Freigabe. Ausnahme: Foto-Prüfung wechselt nie (`AiRules.MayFailOver`). Jeder Versuch steht im Nutzungs-/KI-Protokoll. Der Wechsel wird immer angezeigt: `AiResult.FallbackNote` → Hinweis an der KI-Chat-Antwort (`ChatMessage.AiNote`), Herkunftszeile bei KI-Entwürfen.
|
||||
* **Modelle:** `IAiProvider.ListModelsAsync` (Ollama: `/api/tags`); Admin-Dialog und KI-Chat zeigen die verfügbaren Modelle.
|
||||
* **KI-Chat, Erwachsene:** eigener Anbieter, Modell und System-Prompt je KI-Kanal (`ChatChannel.AiProviderId/AiModel/AiSystemPrompt`); serverseitig nur Rollen mit `AiUse`, Kinder immer mit festem Prompt und Admin-Anbieter. Migration `Phase13AiFallbackChat`.
|
||||
@@ -27,6 +27,7 @@ public static class AiEndpoints
|
||||
admin.MapPut("/providers/{id:guid}", UpdateAsync).Validate<SaveAiProviderRequest>();
|
||||
admin.MapDelete("/providers/{id:guid}", DeleteAsync);
|
||||
admin.MapPost("/providers/{id:guid}/test", TestAsync);
|
||||
admin.MapPost("/models", ModelsAsync);
|
||||
admin.MapGet("/usage", UsageAsync);
|
||||
AiExchangeEndpoints.Map(admin);
|
||||
|
||||
@@ -124,6 +125,45 @@ public static class AiEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Welche Modelle bietet der Anbieter an? (Ollama: installierte Modelle.) Fehler kommen als Meldung im Ergebnis, nicht als Statusfehler.</summary>
|
||||
internal static async Task<Ok<AiModelListDto>> ModelsAsync(AiModelListRequest request, AppDbContext db, AiService ai, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string>? models;
|
||||
if (request.ProviderId is { } id && await db.AiProviders.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id, ct) is { } stored)
|
||||
{
|
||||
var (url, error) = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl);
|
||||
if (url is null)
|
||||
{
|
||||
return TypedResults.Ok(new AiModelListDto(false, [], error));
|
||||
}
|
||||
|
||||
// Ohne neu eingegebenen Schlüssel gilt der gespeicherte (nur bei gleichem Anbietertyp).
|
||||
var key = request.ApiKey ?? (request.Type == stored.Type ? ai.StoredKey(stored) : null);
|
||||
models = await ai.ListModelsAsync(request.Type, url, key, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var (url, error) = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl);
|
||||
if (url is null)
|
||||
{
|
||||
return TypedResults.Ok(new AiModelListDto(false, [], error));
|
||||
}
|
||||
|
||||
models = await ai.ListModelsAsync(request.Type, url, request.ApiKey, ct);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(models is null
|
||||
? new AiModelListDto(false, [], "Dieser Anbietertyp liefert keine Modellliste – bitte den Modellnamen eintragen.")
|
||||
: new AiModelListDto(true, models, models.Count == 0 ? "Keine Modelle gefunden." : null));
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
return TypedResults.Ok(new AiModelListDto(true, [], ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<Ok<AiUsageOverviewDto>> UsageAsync(AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider, CancellationToken ct)
|
||||
{
|
||||
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
|
||||
|
||||
@@ -35,7 +35,25 @@ public sealed record AiResponse(string Text, JsonElement? Json, int? InputTokens
|
||||
/// <summary>Entschlüsselte Verbindungsdaten für einen einzelnen Aufruf (nie loggen oder weitergeben).</summary>
|
||||
public sealed record AiProviderSettings(AiProviderType Type, string BaseUrl, string Model, string? ApiKey);
|
||||
|
||||
public sealed record AiResult(AiResponse Response, string ProviderName, string Model, long DurationMs);
|
||||
/// <summary>Ein Anbieter, der übersprungen wurde, weil er nicht (brauchbar) geantwortet hat.</summary>
|
||||
public sealed record AiFallbackAttempt(string ProviderName, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis eines KI-Aufrufs. <see cref="Failed"/> nennt die Anbieter, die vorher versucht wurden und ausgefallen sind
|
||||
/// (Anbieterwechsel, <see cref="FallbackNote"/>); leer, wenn der erste Anbieter geantwortet hat.
|
||||
/// </summary>
|
||||
public sealed record AiResult(AiResponse Response, string ProviderName, string Model, long DurationMs, IReadOnlyList<AiFallbackAttempt>? Failed = null)
|
||||
{
|
||||
public bool UsedFallback => Failed is { Count: > 0 };
|
||||
|
||||
/// <summary>Deutscher Hinweis für die Oberfläche, z. B. „Ollama war nicht erreichbar – stattdessen hat Claude geantwortet.“ (<c>null</c> ohne Wechsel).</summary>
|
||||
public string? FallbackNote => UsedFallback
|
||||
? $"{string.Join(", ", Failed!.Select(f => $"„{f.ProviderName}“"))} {(Failed!.Count == 1 ? "hat" : "haben")} nicht geantwortet – stattdessen hat „{ProviderName}“ ({Model}) geantwortet."
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Wunsch des Aufrufers: bevorzugter Anbieter und/oder Modell (z. B. im KI-Chat der Erwachsenen). Ohne Angabe gilt die Zuordnung je Zweck.</summary>
|
||||
public sealed record AiRunOptions(Guid? ProviderId = null, string? Model = null);
|
||||
|
||||
public enum AiErrorKind
|
||||
{
|
||||
@@ -76,4 +94,7 @@ public interface IAiProvider
|
||||
AiProviderType Type { get; }
|
||||
|
||||
Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>Verfügbare Modelle beim Anbieter (<c>null</c>, wenn der Adapter keine Liste kennt).</summary>
|
||||
Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderSettings settings, CancellationToken ct) => Task.FromResult<IReadOnlyList<string>?>(null);
|
||||
}
|
||||
@@ -54,6 +54,31 @@ public sealed class OllamaProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
return Build(text, request, AiHttp.Int(json, "prompt_eval_count"), AiHttp.Int(json, "eval_count"));
|
||||
}
|
||||
|
||||
/// <summary>Installierte Modelle (<c>GET {base}/api/tags</c>), alphabetisch.</summary>
|
||||
public async Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderSettings settings, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, AiHttp.Combine(settings.BaseUrl, "api/tags"));
|
||||
if (!string.IsNullOrEmpty(settings.ApiKey))
|
||||
{
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
||||
}
|
||||
|
||||
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), message, ct);
|
||||
var names = new List<string>();
|
||||
if (json.TryGetProperty("models", out var models) && models.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var model in models.EnumerateArray())
|
||||
{
|
||||
if (model.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String && name.GetString() is { Length: > 0 } value)
|
||||
{
|
||||
names.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names.Order(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
internal static AiResponse Build(string text, AiRequest request, int? input, int? output) =>
|
||||
new(text, request.Schema is null ? null : AiHttp.ExtractJson(text), input, output);
|
||||
}
|
||||
@@ -163,6 +188,65 @@ public sealed class ClaudeProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Google AI / Gemini (<c>POST {base}/v1beta/models/{model}:generateContent</c>). Bilder als <c>inlineData</c>, strukturierte Antwort über
|
||||
/// <c>responseMimeType</c> + <c>responseJsonSchema</c>. Rollen: <c>user</c>/<c>model</c>. Keine Web-Suche (mit JSON-Schema nicht kombinierbar).
|
||||
/// </summary>
|
||||
public sealed class GoogleProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
{
|
||||
public AiProviderType Type => AiProviderType.Google;
|
||||
|
||||
public async Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var contents = new JsonArray();
|
||||
foreach (var message in request.Messages)
|
||||
{
|
||||
var parts = new JsonArray();
|
||||
foreach (var image in message.Images ?? [])
|
||||
{
|
||||
parts.Add(new JsonObject { ["inlineData"] = new JsonObject { ["mimeType"] = image.ContentType, ["data"] = AiHttp.Base64(image) } });
|
||||
}
|
||||
|
||||
parts.Add(new JsonObject { ["text"] = message.Text });
|
||||
contents.Add(new JsonObject { ["role"] = message.Role == "assistant" ? "model" : "user", ["parts"] = parts });
|
||||
}
|
||||
|
||||
var generation = new JsonObject { ["maxOutputTokens"] = request.MaxOutputTokens };
|
||||
if (request.Schema is { } schema)
|
||||
{
|
||||
generation["responseMimeType"] = "application/json";
|
||||
generation["responseJsonSchema"] = schema.Schema.DeepClone();
|
||||
}
|
||||
|
||||
var body = new JsonObject { ["contents"] = contents, ["generationConfig"] = generation };
|
||||
if (!string.IsNullOrWhiteSpace(request.System))
|
||||
{
|
||||
body["systemInstruction"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = request.System }) };
|
||||
}
|
||||
|
||||
var path = $"v1beta/models/{Uri.EscapeDataString(settings.Model)}:generateContent";
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, AiHttp.Combine(settings.BaseUrl, path)) { Content = AiHttp.Json(body) };
|
||||
http.Headers.Add("x-goog-api-key", settings.ApiKey ?? string.Empty);
|
||||
|
||||
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), http, ct);
|
||||
var text = new System.Text.StringBuilder();
|
||||
if (json.TryGetProperty("candidates", out var candidates) && candidates.ValueKind == JsonValueKind.Array && candidates.GetArrayLength() > 0
|
||||
&& candidates[0].TryGetProperty("content", out var content) && content.TryGetProperty("parts", out var parts2) && parts2.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var part in parts2.EnumerateArray())
|
||||
{
|
||||
if (part.TryGetProperty("text", out var value) && value.ValueKind == JsonValueKind.String && !(part.TryGetProperty("thought", out var thought) && thought.ValueKind == JsonValueKind.True))
|
||||
{
|
||||
text.Append(value.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var usage = json.TryGetProperty("usageMetadata", out var u) ? u : default;
|
||||
return OllamaProvider.Build(text.ToString(), request, AiHttp.Int(usage, "promptTokenCount"), AiHttp.Int(usage, "candidatesTokenCount"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-kompatible Chat-Completions (<c>POST {base}/chat/completions</c>) – Perplexity, Azure OpenAI, GitHub Models, LM Studio u. a.
|
||||
/// Bilder als <c>image_url</c> mit Data-URL, strukturierte Antwort über <c>response_format</c> (json_schema).
|
||||
|
||||
@@ -62,16 +62,73 @@ public sealed class AiService(
|
||||
?? (!AiRules.FallsBackToDefault(purpose) ? null : active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)));
|
||||
}
|
||||
|
||||
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Führt die Anfrage mit dem Anbieter des Zwecks (oder dem gewünschten aus <paramref name="options"/>) aus. Antwortet er nicht (nicht erreichbar, Zeitlimit,
|
||||
/// Fehler, Limit, unbrauchbare Antwort), werden die übrigen aktiven Anbieter der Reihe nach befragt; <see cref="AiResult.Failed"/> hält den Wechsel fest.
|
||||
/// Die Foto-Prüfung wechselt nur zu Anbietern, die diesem Zweck zugeordnet sind (<see cref="AiRules.MayFailOver"/>); Anbieter ohne Bild-Freigabe werden bei Fotos übersprungen.
|
||||
/// Scheitern alle, gilt der Fehler des ersten Anbieters.
|
||||
/// </summary>
|
||||
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct, AiRunOptions? options = null)
|
||||
{
|
||||
var provider = await ResolveAsync(purpose, ct)
|
||||
?? throw new AiException(AiErrorKind.NotConfigured, "Es ist noch kein KI-Anbieter eingerichtet. Das kann ein Administrator unter „Verwaltung → KI“ tun.");
|
||||
return await RunWithAsync(userId, provider, purpose, request, ct);
|
||||
var candidates = await CandidatesAsync(purpose, request, options, ct);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
throw new AiException(AiErrorKind.NotConfigured, "Es ist noch kein KI-Anbieter eingerichtet. Das kann ein Administrator unter „Verwaltung → KI“ tun.");
|
||||
}
|
||||
|
||||
var failed = new List<AiFallbackAttempt>();
|
||||
AiException? first = null;
|
||||
for (var i = 0; ; i++)
|
||||
{
|
||||
var (provider, model) = candidates[i];
|
||||
try
|
||||
{
|
||||
var result = await RunWithAsync(userId, provider, purpose, request, ct, model);
|
||||
return failed.Count == 0 ? result : result with { Failed = failed };
|
||||
}
|
||||
catch (AiException ex) when (i < candidates.Count - 1 && IsFallbackWorthy(ex.Kind))
|
||||
{
|
||||
first ??= ex;
|
||||
failed.Add(new AiFallbackAttempt(provider.Name, ex.Message));
|
||||
logger.LogWarning("KI-Anbieter {Provider} ausgefallen ({Kind}), versuche den nächsten.", provider.Name, ex.Kind);
|
||||
}
|
||||
catch (AiException) when (first is not null)
|
||||
{
|
||||
throw first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ruft einen bestimmten Anbieter auf (Verbindungstest, Fachfunktionen mit fester Wahl).</summary>
|
||||
public async Task<AiResult> RunWithAsync(Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, CancellationToken ct)
|
||||
/// <summary>Konfigurations-Sperren (Bilder/Suche nicht erlaubt) sind keine Ausfälle – ein anderer Anbieter würde die Regel des Admins umgehen.</summary>
|
||||
private static bool IsFallbackWorthy(AiErrorKind kind) => kind is not (AiErrorKind.ImagesNotAllowed or AiErrorKind.WebSearchNotAllowed or AiErrorKind.NotConfigured);
|
||||
|
||||
private async Task<List<(AiProviderConfig Provider, string? Model)>> CandidatesAsync(AiPurpose purpose, AiRequest request, AiRunOptions? options, CancellationToken ct)
|
||||
{
|
||||
var active = await db.AiProviders.AsNoTracking().Where(p => p.IsActive).OrderBy(p => p.Name).ToListAsync(ct);
|
||||
var fallsBack = AiRules.FallsBackToDefault(purpose);
|
||||
var primary = options?.ProviderId is { } wanted ? active.FirstOrDefault(p => p.Id == wanted) : null;
|
||||
var model = options?.ProviderId is not null && primary is null ? null : options?.Model; // Modell eines nicht mehr vorhandenen Anbieters nicht übernehmen
|
||||
primary ??= active.FirstOrDefault(p => p.Purposes.HasFlag(purpose)) ?? (fallsBack ? active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)) : null);
|
||||
if (primary is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var list = new List<(AiProviderConfig, string?)> { (primary, string.IsNullOrWhiteSpace(model) ? null : model.Trim()) };
|
||||
list.AddRange(active
|
||||
.Where(p => p.Id != primary.Id && (AiRules.MayFailOver(purpose) || p.Purposes.HasFlag(purpose)))
|
||||
.Where(p => (!request.HasImages || AiRules.MayReceiveImages(p.AllowImages, p.BaseUrl))
|
||||
&& (!request.WebSearch || (p.AllowWebSearch && AiRules.SupportsWebSearch(p.Type))))
|
||||
.OrderByDescending(p => p.Purposes.HasFlag(purpose))
|
||||
.ThenByDescending(p => p.Purposes.HasFlag(AiPurpose.Default))
|
||||
.Select(p => (p, (string?)null)));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Ruft einen bestimmten Anbieter auf (Verbindungstest, Fachfunktionen mit fester Wahl); <paramref name="modelOverride"/> ersetzt das Modell des Anbieters.</summary>
|
||||
public async Task<AiResult> RunWithAsync(Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, CancellationToken ct, string? modelOverride = null)
|
||||
{
|
||||
var model = string.IsNullOrWhiteSpace(modelOverride) ? provider.Model : modelOverride;
|
||||
var started = timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
int? inputTokens = null;
|
||||
@@ -95,7 +152,7 @@ public sealed class AiService(
|
||||
|
||||
var adapter = providers.FirstOrDefault(p => p.Type == provider.Type)
|
||||
?? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbietertyp gibt es keinen Adapter.");
|
||||
var settings = new AiProviderSettings(provider.Type, provider.BaseUrl, provider.Model, Key(provider));
|
||||
var settings = new AiProviderSettings(provider.Type, provider.BaseUrl, model, Key(provider));
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
@@ -110,8 +167,8 @@ public sealed class AiService(
|
||||
throw new AiException(AiErrorKind.BadResponse, "Die KI hat nicht im verlangten Format geantwortet.");
|
||||
}
|
||||
|
||||
await LogAsync(userId, provider, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, null, null, rawResponse);
|
||||
return new AiResult(response, provider.Name, provider.Model, stopwatch.ElapsedMilliseconds);
|
||||
await LogAsync(userId, provider, model, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, null, null, rawResponse);
|
||||
return new AiResult(response, provider.Name, model, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (AiException ex) when (ex.Kind == AiErrorKind.BadResponse && attempt < MaxAttempts)
|
||||
{
|
||||
@@ -121,11 +178,35 @@ public sealed class AiService(
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
await LogAsync(userId, provider, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label, ex.Message, rawResponse);
|
||||
await LogAsync(userId, provider, model, purpose, request, started, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label, ex.Message, rawResponse);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verfügbare Modelle eines gespeicherten Anbieters (<c>null</c>: Anbietertyp liefert keine Liste).</summary>
|
||||
public Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderConfig provider, CancellationToken ct) =>
|
||||
ListModelsAsync(provider.Type, provider.BaseUrl, Key(provider), ct);
|
||||
|
||||
/// <summary>Der entschlüsselte gespeicherte Schlüssel (<c>null</c> ohne Schlüssel; wirft <see cref="AiException"/>, wenn er nötig ist, aber fehlt oder nicht lesbar ist).</summary>
|
||||
public string? StoredKey(AiProviderConfig provider) => Key(provider);
|
||||
|
||||
/// <summary>Verfügbare Modelle für die angegebenen Verbindungsdaten (Dialog vor dem Speichern). Kurzes festes Zeitlimit.</summary>
|
||||
public async Task<IReadOnlyList<string>?> ListModelsAsync(AiProviderType type, string baseUrl, string? apiKey, CancellationToken ct)
|
||||
{
|
||||
var adapter = providers.FirstOrDefault(p => p.Type == type)
|
||||
?? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbietertyp gibt es keinen Adapter.");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
try
|
||||
{
|
||||
return await adapter.ListModelsAsync(new AiProviderSettings(type, baseUrl, string.Empty, apiKey), timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new AiException(AiErrorKind.Timeout, "Der KI-Anbieter hat nicht rechtzeitig geantwortet.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureWithinLimitAsync(Guid userId, AiProviderConfig provider, CancellationToken ct)
|
||||
{
|
||||
if (provider.DailyLimitPerUser <= 0)
|
||||
@@ -170,7 +251,7 @@ public sealed class AiService(
|
||||
}
|
||||
|
||||
private async Task LogAsync(
|
||||
Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, DateTimeOffset started, long durationMs, int? input, int? output,
|
||||
Guid userId, AiProviderConfig provider, string model, AiPurpose purpose, AiRequest request, DateTimeOffset started, long durationMs, int? input, int? output,
|
||||
string? error, string? errorMessage, string? response)
|
||||
{
|
||||
db.AiUsage.Add(new AiUsage
|
||||
@@ -178,7 +259,7 @@ public sealed class AiService(
|
||||
UserId = userId,
|
||||
ProviderId = provider.Id,
|
||||
ProviderName = provider.Name,
|
||||
Model = provider.Model,
|
||||
Model = model,
|
||||
Purpose = purpose,
|
||||
Success = error is null,
|
||||
Error = error,
|
||||
@@ -197,7 +278,7 @@ public sealed class AiService(
|
||||
{
|
||||
UserId = userId,
|
||||
ProviderName = provider.Name,
|
||||
Model = provider.Model,
|
||||
Model = model,
|
||||
Purpose = purpose,
|
||||
StartedAt = started,
|
||||
DurationMs = durationMs,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Features.Ai;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen des eigenen KI-Kanals – nur für Erwachsene (nie Kinder, serverseitig geprüft): Anbieter, Modell und eigener System-Prompt.
|
||||
/// Ohne Angabe gilt die Zuordnung des Admins und der feste, kindgerechte Standard-Prompt.
|
||||
/// </summary>
|
||||
internal static class ChatAiSettingsEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAiSettingsEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/channels/{id:guid}/ai-settings", GetAsync);
|
||||
group.MapPut("/channels/{id:guid}/ai-settings", SaveAsync).Validate<SaveChatAiSettingsRequest>();
|
||||
group.MapGet("/channels/{id:guid}/ai-models", ModelsAsync);
|
||||
return group;
|
||||
}
|
||||
|
||||
internal static async Task<Results<Ok<ChatAiSettingsDto>, NotFound, ForbidHttpResult>> GetAsync(
|
||||
Guid id, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, CancellationToken ct)
|
||||
{
|
||||
var actor = await access.ActorAsync(user, ct);
|
||||
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
|
||||
{
|
||||
return TypedResults.Forbid();
|
||||
}
|
||||
|
||||
var channel = await access.ChannelAsync(actor, id, ct);
|
||||
return channel is not { Kind: ChatChannelKind.Ai } ? TypedResults.NotFound() : TypedResults.Ok(await ToDtoAsync(db, channel, ct));
|
||||
}
|
||||
|
||||
internal static async Task<Results<Ok<ChatAiSettingsDto>, NotFound, ForbidHttpResult, ValidationProblem>> SaveAsync(
|
||||
Guid id, SaveChatAiSettingsRequest request, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, CancellationToken ct)
|
||||
{
|
||||
var actor = await access.ActorAsync(user, ct);
|
||||
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
|
||||
{
|
||||
return TypedResults.Forbid();
|
||||
}
|
||||
|
||||
if (await access.ChannelAsync(actor, id, ct) is not { Kind: ChatChannelKind.Ai })
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (request.ProviderId is { } providerId && !await db.AiProviders.AnyAsync(p => p.Id == providerId && p.IsActive, ct))
|
||||
{
|
||||
return ChatEndpoints.Invalid(nameof(SaveChatAiSettingsRequest.ProviderId), "Dieser Anbieter ist nicht (mehr) verfügbar.");
|
||||
}
|
||||
|
||||
var model = string.IsNullOrWhiteSpace(request.Model) ? null : request.Model.Trim();
|
||||
var prompt = ChatRules.NormalizeText(request.SystemPrompt);
|
||||
await db.ChatChannels.Where(c => c.Id == id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(c => c.AiProviderId, request.ProviderId)
|
||||
.SetProperty(c => c.AiModel, model)
|
||||
.SetProperty(c => c.AiSystemPrompt, prompt), ct);
|
||||
|
||||
var channel = await db.ChatChannels.AsNoTracking().SingleAsync(c => c.Id == id, ct);
|
||||
return TypedResults.Ok(await ToDtoAsync(db, channel, ct));
|
||||
}
|
||||
|
||||
/// <summary>Modelle des gewählten Anbieters (ohne Angabe: der dem KI-Chat zugeordnete); Fehler als Meldung im Ergebnis.</summary>
|
||||
internal static async Task<Results<Ok<AiModelListDto>, NotFound, ForbidHttpResult>> ModelsAsync(
|
||||
Guid id, Guid? providerId, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, AiService ai, CancellationToken ct)
|
||||
{
|
||||
var actor = await access.ActorAsync(user, ct);
|
||||
if (!ChatRules.CanCustomizeAiChat(actor.Roles))
|
||||
{
|
||||
return TypedResults.Forbid();
|
||||
}
|
||||
|
||||
if (await access.ChannelAsync(actor, id, ct) is not { Kind: ChatChannelKind.Ai })
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var provider = providerId is { } pid
|
||||
? await db.AiProviders.AsNoTracking().SingleOrDefaultAsync(p => p.Id == pid && p.IsActive, ct)
|
||||
: await ai.ResolveAsync(AiPurpose.ChatAssistant, ct);
|
||||
if (provider is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var models = await ai.ListModelsAsync(provider, ct);
|
||||
return TypedResults.Ok(models is null
|
||||
? new AiModelListDto(false, [], "Dieser Anbieter liefert keine Modellliste – bitte den Modellnamen eintragen.")
|
||||
: new AiModelListDto(true, models, models.Count == 0 ? "Keine Modelle gefunden." : null));
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
return TypedResults.Ok(new AiModelListDto(true, [], ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ChatAiSettingsDto> ToDtoAsync(AppDbContext db, ChatChannel channel, CancellationToken ct)
|
||||
{
|
||||
var providers = await db.AiProviders.AsNoTracking().Where(p => p.IsActive).OrderBy(p => p.Name).ToListAsync(ct);
|
||||
return new ChatAiSettingsDto(
|
||||
channel.AiProviderId,
|
||||
channel.AiModel,
|
||||
channel.AiSystemPrompt,
|
||||
ChatAssistantPrompt.System,
|
||||
providers.Select(p => new ChatAiProviderOptionDto(p.Id, p.Name, p.Type, p.Model, !AiRules.IsHomeNetwork(p.BaseUrl))).ToList());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public static class ChatEndpoints
|
||||
group.MapGet("/features", GetFeaturesAsync);
|
||||
group.MapPost("/ai", StartAiAsync);
|
||||
group.MapGifEndpoints();
|
||||
group.MapAiSettingsEndpoints();
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -196,7 +197,8 @@ public static class ChatEndpoints
|
||||
var actor = await access.ActorAsync(user, ct);
|
||||
var gif = gifs.IsConfigured && (actor.CanFamilyChat || actor.CanDirect);
|
||||
var provider = actor.CanAiChat ? await ai.ResolveAsync(AiPurpose.ChatAssistant, ct) : null;
|
||||
return TypedResults.Ok(new ChatFeaturesDto(gif, provider is not null, provider is not null && !AiRules.IsHomeNetwork(provider.BaseUrl)));
|
||||
return TypedResults.Ok(new ChatFeaturesDto(
|
||||
gif, provider is not null, provider is not null && !AiRules.IsHomeNetwork(provider.BaseUrl), actor.CanAiChat && ChatRules.CanCustomizeAiChat(actor.Roles)));
|
||||
}
|
||||
|
||||
// ---------- Verlauf ----------
|
||||
@@ -307,12 +309,19 @@ public static class ChatEndpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
// Eigene Wahl (Anbieter, Modell, System-Prompt) gilt nur für Erwachsene – auch wenn die Rolle sich nach dem Speichern geändert hat.
|
||||
var custom = ChatRules.CanCustomizeAiChat(actor.Roles);
|
||||
var system = custom && !string.IsNullOrWhiteSpace(channel.AiSystemPrompt) ? channel.AiSystemPrompt : ChatAssistantPrompt.System;
|
||||
var options = custom ? new AiRunOptions(channel.AiProviderId, channel.AiModel) : null;
|
||||
|
||||
string? reply;
|
||||
string? note;
|
||||
try
|
||||
{
|
||||
var result = await ai.RunAsync(
|
||||
actor.UserId, AiPurpose.ChatAssistant, new AiRequest(ChatAssistantPrompt.System, messages, MaxOutputTokens: ChatRules.AiMaxOutputTokens), ct);
|
||||
actor.UserId, AiPurpose.ChatAssistant, new AiRequest(system, messages, MaxOutputTokens: ChatRules.AiMaxOutputTokens), ct, options);
|
||||
reply = ChatAssistantPrompt.NormalizeReply(result.Response.Text);
|
||||
note = result.FallbackNote is { Length: > ChatChannel.AiNoteMaxLength } text ? text[..ChatChannel.AiNoteMaxLength] : result.FallbackNote;
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
@@ -324,7 +333,7 @@ public static class ChatEndpoints
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Chat", detail: "Die KI hat keine Antwort geliefert. Versuch es noch einmal.");
|
||||
}
|
||||
|
||||
var answer = new ChatMessage { ChannelId = channel.Id, SenderId = ChatRules.AiSenderId, Text = reply };
|
||||
var answer = new ChatMessage { ChannelId = channel.Id, SenderId = ChatRules.AiSenderId, Text = reply, AiNote = note };
|
||||
db.ChatMessages.Add(answer);
|
||||
await db.SaveChangesAsync(ct);
|
||||
var answeredAt = answer.CreatedAt;
|
||||
@@ -662,7 +671,8 @@ public static class ChatEndpoints
|
||||
m.Text,
|
||||
m.HasImage ? $"/api/chat/attachments/{m.Id}" : null,
|
||||
m.CreatedAt,
|
||||
reactions.GetValueOrDefault(m.Id) ?? [])).ToList();
|
||||
reactions.GetValueOrDefault(m.Id) ?? [],
|
||||
m.AiNote)).ToList();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, List<ChatReactionDto>>> ReactionsAsync(AppDbContext db, IReadOnlyCollection<Guid> messageIds, CancellationToken ct)
|
||||
|
||||
@@ -48,7 +48,8 @@ internal static class LearningAiDraftPipeline
|
||||
{
|
||||
Source = LearningSource.Ai,
|
||||
AiPrompt = LearningAiPrompt.StoredPrompt(request),
|
||||
AiProvider = Truncate(result.ProviderName, LearningSet.AiProviderMaxLength),
|
||||
// Anbieterwechsel bleibt sichtbar (AiDraftNotice zeigt „Erstellt mit …“).
|
||||
AiProvider = Truncate(result.UsedFallback ? $"{result.ProviderName} – Ersatz für {string.Join(", ", result.Failed!.Select(f => f.ProviderName))}" : result.ProviderName, LearningSet.AiProviderMaxLength),
|
||||
AiModel = Truncate(result.Model, LearningSet.AiProviderMaxLength),
|
||||
};
|
||||
LearningManageEndpoints.ApplySet(set, fields);
|
||||
|
||||
@@ -47,6 +47,7 @@ builder.Services.AddSingleton<IAiSecretProtector, AiSecretProtector>();
|
||||
builder.Services.AddScoped<IAiProvider, OllamaProvider>();
|
||||
builder.Services.AddScoped<IAiProvider, ClaudeProvider>();
|
||||
builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
|
||||
builder.Services.AddScoped<IAiProvider, GoogleProvider>();
|
||||
builder.Services.AddScoped<ChatNotifier>();
|
||||
|
||||
// GIF-Suche (ADR-0022): Giphy hinter IGifProvider; ohne Schlüssel (Giphy__ApiKey) bleibt die Funktion aus. Keine Weiterleitungen, kein Anfrage-Protokoll
|
||||
|
||||
@@ -12,6 +12,9 @@ public enum AiProviderType
|
||||
|
||||
/// <summary>Jede OpenAI-kompatible Chat-Completions-Schnittstelle (Perplexity, Azure OpenAI, GitHub Models, LM Studio …).</summary>
|
||||
OpenAiCompatible = 2,
|
||||
|
||||
/// <summary>Google AI (Gemini API, <c>generateContent</c>).</summary>
|
||||
Google = 3,
|
||||
}
|
||||
|
||||
/// <summary>Wofür ein Anbieter eingesetzt wird. Je Zweck ist höchstens ein Anbieter zugeordnet.</summary>
|
||||
|
||||
@@ -27,6 +27,8 @@ public static class AiRules
|
||||
"Läuft lokal im Heimnetz; Bilder nur mit einem Vision-Modell (z. B. qwen2.5vl, llava)."),
|
||||
new(AiProviderType.Claude, "Claude (Anthropic)", "https://api.anthropic.com", "claude-sonnet-5-5", true,
|
||||
"Cloud-Dienst mit API-Schlüssel; Aufgaben und Fotos werden an Anthropic gesendet."),
|
||||
new(AiProviderType.Google, "Google AI (Gemini)", "https://generativelanguage.googleapis.com", "gemini-2.5-flash", true,
|
||||
"Cloud-Dienst mit API-Schlüssel (Google AI Studio); Aufgaben und Fotos werden an Google gesendet."),
|
||||
new(AiProviderType.OpenAiCompatible, "OpenAI-kompatibel (z. B. Perplexity)", "https://api.perplexity.ai", "sonar", false,
|
||||
"Für Perplexity, Azure OpenAI, GitHub Models oder lokale Server wie LM Studio. Microsoft Copilot selbst hat keine offene Schnittstelle."),
|
||||
];
|
||||
@@ -61,6 +63,12 @@ public static class AiRules
|
||||
/// </summary>
|
||||
public static bool FallsBackToDefault(AiPurpose purpose) => purpose is not (AiPurpose.LearningImageGrading or AiPurpose.ChatAssistant);
|
||||
|
||||
/// <summary>
|
||||
/// Darf bei einem Ausfall des Anbieters ein anderer aktiver Anbieter einspringen? Ja, außer bei der Foto-Prüfung (ein Anbieter ohne Vision-Modell
|
||||
/// würde Fotos „lesen“, ohne sie zu sehen). Der Wechsel wird der Person immer angezeigt (auch im KI-Chat), geht also nie unbemerkt.
|
||||
/// </summary>
|
||||
public static bool MayFailOver(AiPurpose purpose) => purpose != AiPurpose.LearningImageGrading;
|
||||
|
||||
/// <summary>Genau ein einzelner, bekannter Zweck?</summary>
|
||||
public static bool IsSinglePurpose(AiPurpose purpose) => SelectablePurposes.Contains(purpose);
|
||||
|
||||
@@ -97,10 +105,10 @@ public static class AiRules
|
||||
return (null, "Diese Adresse ist nicht erlaubt.");
|
||||
}
|
||||
|
||||
if (uri.Scheme == "http" && (type == AiProviderType.Claude || !IsHomeNetwork(uri)))
|
||||
if (uri.Scheme == "http" && (type is AiProviderType.Claude or AiProviderType.Google || !IsHomeNetwork(uri)))
|
||||
{
|
||||
return (null, type == AiProviderType.Claude
|
||||
? "Claude ist nur über https erreichbar."
|
||||
return (null, type is AiProviderType.Claude or AiProviderType.Google
|
||||
? $"{Info(type).DisplayName.Split(' ')[0]} ist nur über https erreichbar."
|
||||
: "Außerhalb des Heimnetzes ist nur https erlaubt.");
|
||||
}
|
||||
|
||||
@@ -139,7 +147,7 @@ public static class AiRules
|
||||
/// <summary>Braucht der Anbieter einen API-Schlüssel? Claude immer, andere nur außerhalb des Heimnetzes (Ollama nie).</summary>
|
||||
public static bool RequiresApiKey(AiProviderType type, string baseUrl) => type switch
|
||||
{
|
||||
AiProviderType.Claude => true,
|
||||
AiProviderType.Claude or AiProviderType.Google => true,
|
||||
AiProviderType.Ollama => false,
|
||||
_ => !IsHomeNetwork(baseUrl),
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ public enum ChatChannelKind
|
||||
public sealed class ChatChannel : AuditableEntity
|
||||
{
|
||||
public const int DirectKeyMaxLength = 80;
|
||||
public const int AiModelMaxLength = 100;
|
||||
public const int AiSystemPromptMaxLength = 2000;
|
||||
public const int AiNoteMaxLength = 400;
|
||||
|
||||
public ChatChannelKind Kind { get; set; }
|
||||
|
||||
@@ -25,6 +28,15 @@ public sealed class ChatChannel : AuditableEntity
|
||||
|
||||
/// <summary>Zeitpunkt der letzten Nachricht (Sortierung der Kanalliste); <c>null</c> solange nichts geschrieben wurde.</summary>
|
||||
public DateTimeOffset? LastMessageAt { get; set; }
|
||||
|
||||
/// <summary>KI-Kanal, nur Erwachsene: bevorzugter Anbieter (<c>null</c> = der dem KI-Chat zugeordnete).</summary>
|
||||
public Guid? AiProviderId { get; set; }
|
||||
|
||||
/// <summary>KI-Kanal, nur Erwachsene: Modell, das das Modell des Anbieters überschreibt (<c>null</c> = Modell des Anbieters).</summary>
|
||||
public string? AiModel { get; set; }
|
||||
|
||||
/// <summary>KI-Kanal, nur Erwachsene: eigener System-Prompt (<c>null</c> = fester, kindgerechter Standard-Prompt).</summary>
|
||||
public string? AiSystemPrompt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,6 +67,9 @@ public sealed class ChatMessage : AuditableEntity, ISoftDeletable
|
||||
/// <summary>Das Bild liegt in <see cref="ChatAttachment"/> (gleiche Id wie die Nachricht).</summary>
|
||||
public bool HasImage { get; set; }
|
||||
|
||||
/// <summary>KI-Antworten: Hinweis, wenn ein anderer Anbieter als der gewählte geantwortet hat (Anbieterwechsel).</summary>
|
||||
public string? AiNote { get; set; }
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
@@ -63,6 +63,9 @@ public static class ChatRules
|
||||
return PermissionMatrix.For(Permission.AiUse, list) != AccessLevel.None || (childrenAllowed && list.Contains(Roles.Child));
|
||||
}
|
||||
|
||||
/// <summary>Anbieter, Modell und System-Prompt im KI-Chat ändern: nur Erwachsene (Rollen mit <see cref="Permission.AiUse"/>), nie Kinder.</summary>
|
||||
public static bool CanCustomizeAiChat(IEnumerable<string> roles) => PermissionMatrix.For(Permission.AiUse, roles.ToList()) != AccessLevel.None;
|
||||
|
||||
/// <summary>Eindeutiger Schlüssel des KI-Kanals einer Person (je Person genau einer).</summary>
|
||||
public static string AiKey(Guid userId) => $"ai:{userId:N}";
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ internal sealed class ChatChannelConfiguration : IEntityTypeConfiguration<ChatCh
|
||||
builder.ToTable("ChatChannels");
|
||||
builder.Property(c => c.Kind).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(c => c.DirectKey).HasMaxLength(ChatChannel.DirectKeyMaxLength);
|
||||
builder.Property(c => c.AiModel).HasMaxLength(ChatChannel.AiModelMaxLength);
|
||||
builder.Property(c => c.AiSystemPrompt).HasMaxLength(ChatChannel.AiSystemPromptMaxLength);
|
||||
|
||||
// Genau ein Familienchat und je Personenpaar höchstens ein Direktkanal.
|
||||
builder.HasIndex(c => c.Kind).IsUnique().HasFilter("[Kind] = 'Family'");
|
||||
@@ -35,6 +37,7 @@ internal sealed class ChatMessageConfiguration : IEntityTypeConfiguration<ChatMe
|
||||
{
|
||||
builder.ToTable("ChatMessages");
|
||||
builder.Property(m => m.Text).HasMaxLength(ChatMessage.TextMaxLength);
|
||||
builder.Property(m => m.AiNote).HasMaxLength(ChatChannel.AiNoteMaxLength);
|
||||
builder.HasOne<ChatChannel>().WithMany().HasForeignKey(m => m.ChannelId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Seitenweises Laden: neueste zuerst, stabil über (CreatedAt, Id).
|
||||
|
||||
+2771
File diff suppressed because it is too large.
Load diff
+62
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase13AiFallbackChat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AiNote",
|
||||
table: "ChatMessages",
|
||||
type: "nvarchar(400)",
|
||||
maxLength: 400,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AiModel",
|
||||
table: "ChatChannels",
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "AiProviderId",
|
||||
table: "ChatChannels",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AiSystemPrompt",
|
||||
table: "ChatChannels",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiNote",
|
||||
table: "ChatMessages");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiModel",
|
||||
table: "ChatChannels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiProviderId",
|
||||
table: "ChatChannels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiSystemPrompt",
|
||||
table: "ChatChannels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,6 +661,17 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AiModel")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<Guid?>("AiProviderId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AiSystemPrompt")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
@@ -741,6 +752,10 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AiNote")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("nvarchar(400)");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
|
||||
@@ -94,6 +94,15 @@ public sealed record AiExchangeFilter(AiPurpose? Purpose = null, bool? Success =
|
||||
|
||||
public sealed record AiExchangePersonDto(Guid Id, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Modelle auflisten (Admin): entweder ein gespeicherter Anbieter (<see cref="ProviderId"/>, der gespeicherte Schlüssel wird genutzt) oder die noch
|
||||
/// ungespeicherten Eingaben des Dialogs. Ein leerer <see cref="BaseUrl"/> nimmt die Voreinstellung des Typs.
|
||||
/// </summary>
|
||||
public sealed record AiModelListRequest(AiProviderType Type, string? BaseUrl, string? ApiKey = null, Guid? ProviderId = null);
|
||||
|
||||
/// <summary>Verfügbare Modelle. <see cref="Supported"/> ist <c>false</c>, wenn der Anbietertyp keine Liste liefert (dann Modell frei eingeben).</summary>
|
||||
public sealed record AiModelListDto(bool Supported, IReadOnlyList<string> Models, string? Message = null);
|
||||
|
||||
public sealed record AiUsageOverviewDto(IReadOnlyList<AiUsageDto> Recent, int CallsToday, int FailedToday);
|
||||
|
||||
/// <summary>
|
||||
@@ -152,6 +161,7 @@ public sealed class SaveAiProviderRequestValidator : AbstractValidator<SaveAiPro
|
||||
public static class AiApiRoutes
|
||||
{
|
||||
public const string Providers = "api/admin/ai/providers";
|
||||
public const string Models = "api/admin/ai/models";
|
||||
public const string Usage = "api/admin/ai/usage";
|
||||
public const string Exchanges = "api/admin/ai/exchanges";
|
||||
public const string ExchangePeople = "api/admin/ai/exchanges/people";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FluentValidation;
|
||||
|
||||
@@ -33,7 +34,8 @@ public sealed record ChatMessageDto(
|
||||
string? Text,
|
||||
string? ImageUrl,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<ChatReactionDto> Reactions);
|
||||
IReadOnlyList<ChatReactionDto> Reactions,
|
||||
string? AiNote = null);
|
||||
|
||||
/// <summary>Gelesen-Stand einer Person (für „Gelesen“-Haken).</summary>
|
||||
public sealed record ChatReadStateDto(Guid UserId, DateTimeOffset LastReadAt);
|
||||
@@ -55,7 +57,23 @@ public sealed record SetChatReactionRequest(string Emoji, bool Active);
|
||||
public sealed record ChatReactionsChangedDto(Guid ChannelId, Guid MessageId, IReadOnlyList<ChatReactionDto> Reactions);
|
||||
|
||||
/// <summary>Was der Chat für die angemeldete Person anbietet (Buttons erscheinen nur, wenn es wirklich geht; ADR-0022).</summary>
|
||||
public sealed record ChatFeaturesDto(bool GifSearchAvailable, bool AiChatAvailable, bool AiChatSendsToCloud);
|
||||
public sealed record ChatFeaturesDto(bool GifSearchAvailable, bool AiChatAvailable, bool AiChatSendsToCloud, bool CanCustomizeAi = false);
|
||||
|
||||
/// <summary>Ein wählbarer Anbieter im KI-Chat (nur Erwachsene).</summary>
|
||||
public sealed record ChatAiProviderOptionDto(Guid Id, string Name, AiProviderType Type, string DefaultModel, bool IsCloud);
|
||||
|
||||
/// <summary>
|
||||
/// Einstellungen des eigenen KI-Kanals (nur Erwachsene): <see cref="ProviderId"/>/<see cref="Model"/> <c>null</c> = Zuordnung des Admins,
|
||||
/// <see cref="SystemPrompt"/> <c>null</c> = <see cref="DefaultSystemPrompt"/>.
|
||||
/// </summary>
|
||||
public sealed record ChatAiSettingsDto(
|
||||
Guid? ProviderId,
|
||||
string? Model,
|
||||
string? SystemPrompt,
|
||||
string DefaultSystemPrompt,
|
||||
IReadOnlyList<ChatAiProviderOptionDto> Providers);
|
||||
|
||||
public sealed record SaveChatAiSettingsRequest(Guid? ProviderId, string? Model, string? SystemPrompt);
|
||||
|
||||
/// <summary>Ein Treffer der GIF-Suche. Die Vorschau kommt über den eigenen Server (<see cref="PreviewUrl"/>), nie direkt vom Anbieter.</summary>
|
||||
public sealed record GifResultDto(string Id, string Title, string PreviewUrl);
|
||||
@@ -92,6 +110,16 @@ public sealed class SendChatMessageRequestValidator : AbstractValidator<SendChat
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SaveChatAiSettingsRequestValidator : AbstractValidator<SaveChatAiSettingsRequest>
|
||||
{
|
||||
public SaveChatAiSettingsRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Model).MaximumLength(ChatChannel.AiModelMaxLength).WithMessage($"Das Modell darf höchstens {ChatChannel.AiModelMaxLength} Zeichen haben.");
|
||||
RuleFor(r => r.SystemPrompt).MaximumLength(ChatChannel.AiSystemPromptMaxLength)
|
||||
.WithMessage($"Der System-Prompt darf höchstens {ChatChannel.AiSystemPromptMaxLength} Zeichen haben.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StartDirectChatRequestValidator : AbstractValidator<StartDirectChatRequest>
|
||||
{
|
||||
public StartDirectChatRequestValidator()
|
||||
|
||||
@@ -28,6 +28,26 @@
|
||||
<MudTextField @bind-Value="_model" Label="Modell" InputId="ai-model" Variant="Variant.Outlined" Placeholder="@TypeInfo.DefaultModel"
|
||||
Error="@HasError("Model")" ErrorText="@ErrorFor("Model")" />
|
||||
|
||||
<div class="fp-ai-models">
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="LoadModelsAsync" Disabled="_loadingModels"
|
||||
Class="fp-ai-models-load">@(_loadingModels ? "Wird geladen …" : "Verfügbare Modelle laden")</MudButton>
|
||||
@if (_modelsMessage is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-ai-models-message" role="status">@_modelsMessage</MudText>
|
||||
}
|
||||
|
||||
@if (_models.Count > 0)
|
||||
{
|
||||
<MudSelect T="string" Value="@(_models.Contains(_model) ? _model : null)" ValueChanged="m => _model = m" Label="Verfügbare Modelle"
|
||||
Variant="Variant.Outlined" Class="fp-ai-model-select" HelperText="Auswahl übernimmt das Modell in das Feld oben.">
|
||||
@foreach (var model in _models)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@model">@model</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</div>
|
||||
|
||||
<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."
|
||||
@@ -70,6 +90,10 @@
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-ai-websearch-unsupported">Online-Suche: von Ollama nicht unterstützt (die Web-Suche von Ollama braucht einen Cloud-Schlüssel).</MudText>
|
||||
}
|
||||
else if (_type == AiProviderType.Google)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-ai-websearch-unsupported">Online-Suche: für Google AI hier nicht unterstützt.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="fp-ai-websearch-builtin">
|
||||
@@ -144,8 +168,33 @@
|
||||
(p.Name, p.Type, p.BaseUrl, p.Model, p.TimeoutSeconds, p.DailyLimitPerUser, p.IsActive, p.AllowImages, p.Purposes, p.AllowWebSearch);
|
||||
}
|
||||
|
||||
private List<string> _models = [];
|
||||
private string? _modelsMessage;
|
||||
private bool _loadingModels;
|
||||
|
||||
private async Task LoadModelsAsync()
|
||||
{
|
||||
_loadingModels = true;
|
||||
_modelsMessage = null;
|
||||
var result = await Api.GetModelsAsync(new AiModelListRequest(
|
||||
_type, string.IsNullOrWhiteSpace(_baseUrl) ? null : _baseUrl.Trim(), string.IsNullOrEmpty(_apiKey) ? null : _apiKey.Trim(), Provider?.Id));
|
||||
_loadingModels = false;
|
||||
if (result.Value is { } list)
|
||||
{
|
||||
_models = list.Models.ToList();
|
||||
_modelsMessage = list.Message ?? $"{list.Models.Count} Modelle gefunden.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_models = [];
|
||||
_modelsMessage = result.Problem?.Message ?? "Die Modelle konnten nicht geladen werden.";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTypeChanged(AiProviderType type)
|
||||
{
|
||||
_models = [];
|
||||
_modelsMessage = null;
|
||||
// Voreinstellungen nur übernehmen, solange der Benutzer nichts Eigenes eingetragen hat.
|
||||
var oldInfo = TypeInfo;
|
||||
_type = type;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
@using FamilyPlanner.Domain.Chat
|
||||
@using FamilyPlanner.Shared.Ai
|
||||
@using FamilyPlanner.Shared.Chat
|
||||
@inject IChatApi Api
|
||||
|
||||
@* KI-Chat-Einstellungen für Erwachsene: Anbieter, Modell und eigener System-Prompt. Kinder sehen diesen Dialog nie (die Api lehnt ab). *@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else if (_settings is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="fp-ai-settings-error">@(_error ?? "Die Einstellungen konnten nicht geladen werden.")</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" role="alert" Class="fp-ai-settings-error">@_error</MudAlert>
|
||||
}
|
||||
|
||||
<MudSelect T="Guid?" Value="_providerId" ValueChanged="OnProviderChangedAsync" Label="Anbieter" Variant="Variant.Outlined" Class="fp-ai-settings-provider"
|
||||
Clearable="true" HelperText="Leer = der vom Administrator zugeordnete Anbieter. Fällt der Anbieter aus, wechselt der Chat automatisch (mit Hinweis).">
|
||||
@foreach (var provider in _settings.Providers)
|
||||
{
|
||||
<MudSelectItem T="Guid?" Value="@((Guid?)provider.Id)">@provider.Name@(provider.IsCloud ? " (Internet)" : string.Empty)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField T="string" @bind-Value="_model" Label="Modell" Variant="Variant.Outlined" Class="fp-ai-settings-model" MaxLength="@ChatChannel.AiModelMaxLength"
|
||||
Placeholder="@(CurrentDefaultModel ?? "Modell des Anbieters")" HelperText="Leer = Modell des Anbieters." />
|
||||
|
||||
<div>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="LoadModelsAsync" Disabled="_loadingModels"
|
||||
Class="fp-ai-settings-models-load">@(_loadingModels ? "Wird geladen …" : "Verfügbare Modelle laden")</MudButton>
|
||||
@if (_modelsMessage is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption" role="status">@_modelsMessage</MudText>
|
||||
}
|
||||
|
||||
@if (_models.Count > 0)
|
||||
{
|
||||
<MudSelect T="string" Value="@(_model is not null && _models.Contains(_model) ? _model : null)" ValueChanged="m => _model = m" Label="Verfügbare Modelle"
|
||||
Variant="Variant.Outlined" Class="fp-ai-settings-model-select">
|
||||
@foreach (var model in _models)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@model">@model</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudTextField T="string" @bind-Value="_prompt" Label="System-Prompt" Variant="Variant.Outlined" Lines="6" Sizing="InputSizing.Auto" MaxLength="@ChatChannel.AiSystemPromptMaxLength"
|
||||
Class="fp-ai-settings-prompt" Placeholder="@_settings.DefaultSystemPrompt"
|
||||
HelperText="Leer = fester, kindgerechter Standard-Prompt. Gilt nur für deinen eigenen KI-Chat." />
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="() => _prompt = _settings.DefaultSystemPrompt" Class="fp-ai-settings-prompt-default">Standard-Prompt einfügen</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="() => Dialog?.Cancel()" Disabled="_busy">Abbrechen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveAsync" Disabled="@(_busy || _settings is null)" Class="fp-dialog-save">Speichern</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance? Dialog { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
private ChatAiSettingsDto? _settings;
|
||||
private Guid? _providerId;
|
||||
private string? _model;
|
||||
private string? _prompt;
|
||||
private List<string> _models = [];
|
||||
private string? _modelsMessage;
|
||||
private string? _error;
|
||||
private bool _loading = true;
|
||||
private bool _loadingModels;
|
||||
private bool _busy;
|
||||
|
||||
private string? CurrentDefaultModel => _settings?.Providers.FirstOrDefault(p => p.Id == _providerId)?.DefaultModel;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var result = await Api.GetAiSettingsAsync(ChannelId);
|
||||
_loading = false;
|
||||
if (result.Value is { } settings)
|
||||
{
|
||||
(_settings, _providerId, _model, _prompt) = (settings, settings.ProviderId, settings.Model, settings.SystemPrompt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnProviderChangedAsync(Guid? providerId)
|
||||
{
|
||||
_providerId = providerId;
|
||||
_models = [];
|
||||
_modelsMessage = null;
|
||||
_model = null; // das Modell gehört zum Anbieter
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task LoadModelsAsync()
|
||||
{
|
||||
_loadingModels = true;
|
||||
_modelsMessage = null;
|
||||
var result = await Api.GetAiModelsAsync(ChannelId, _providerId);
|
||||
_loadingModels = false;
|
||||
if (result.Value is { } list)
|
||||
{
|
||||
_models = list.Models.ToList();
|
||||
_modelsMessage = list.Message ?? $"{list.Models.Count} Modelle gefunden.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_models = [];
|
||||
_modelsMessage = result.Problem?.Message ?? "Die Modelle konnten nicht geladen werden.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
var request = new SaveChatAiSettingsRequest(
|
||||
_providerId,
|
||||
string.IsNullOrWhiteSpace(_model) ? null : _model.Trim(),
|
||||
string.IsNullOrWhiteSpace(_prompt) || _prompt.Trim() == _settings?.DefaultSystemPrompt ? null : _prompt.Trim());
|
||||
var validation = new SaveChatAiSettingsRequestValidator().Validate(request);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_error = string.Join(" ", validation.Errors.Select(e => e.ErrorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
var result = await Api.SaveAiSettingsAsync(ChannelId, request);
|
||||
_busy = false;
|
||||
if (result.Problem is { } problem)
|
||||
{
|
||||
_error = problem.Errors is { Count: > 0 } errors ? string.Join(" ", errors.SelectMany(e => e.Value)) : problem.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog?.Close(DialogResult.Ok(true));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@
|
||||
{
|
||||
<span class="fp-chat-text">@Message.Text</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Message.AiNote))
|
||||
{
|
||||
<span class="fp-chat-ai-note" role="note"><MudIcon Icon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small" /> Anbieter gewechselt: @Message.AiNote</span>
|
||||
}
|
||||
<span class="fp-chat-meta">
|
||||
<span class="fp-chat-time">@ChatFormat.Time(Message.CreatedAt)</span>
|
||||
@if (IsOwn)
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
<header class="fp-chat-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="chat" Class="fp-chat-back" aria-label="Zurück zu den Unterhaltungen" />
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2">@channel.Title</MudText>
|
||||
@if (channel.Kind == ChatChannelKind.Ai && Store.Features.CanCustomizeAi)
|
||||
{
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Tune" OnClick="OpenAiSettingsAsync" aria-label="KI-Einstellungen (Anbieter, Modell, Prompt)" Class="fp-chat-ai-settings" />
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (channel.Kind == ChatChannelKind.Ai)
|
||||
@@ -448,6 +453,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenAiSettingsAsync()
|
||||
{
|
||||
if (ChannelId is not { } id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters<ChatAiSettingsDialog> { { d => d.ChannelId, id } };
|
||||
var dialog = await DialogService.ShowAsync<ChatAiSettingsDialog>("KI-Einstellungen", parameters, new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
if (await dialog.Result is { Canceled: false })
|
||||
{
|
||||
Snackbar.Add("KI-Einstellungen gespeichert.", Severity.Success);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAiAsync()
|
||||
{
|
||||
if (await Store.StartAiAsync() is { } channel)
|
||||
|
||||
@@ -21,6 +21,10 @@ public interface IAiApi
|
||||
|
||||
Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Verfügbare Modelle beim Anbieter (z. B. installierte Ollama-Modelle). Standardimplementierung für Testdoubles.</summary>
|
||||
Task<ApiResult<AiModelListDto>> GetModelsAsync(AiModelListRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<AiModelListDto>.Ok(new AiModelListDto(false, [])));
|
||||
|
||||
/// <summary>KI-Protokoll (Admin): Seite der Aufrufe, gefiltert. Standardimplementierungen für Testdoubles.</summary>
|
||||
Task<ApiResult<PagedResult<AiExchangeListItemDto>>> GetExchangesAsync(AiExchangeFilter filter, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<PagedResult<AiExchangeListItemDto>>.Ok(new PagedResult<AiExchangeListItemDto>([], 0, filter.Page, filter.PageSize)));
|
||||
@@ -105,6 +109,9 @@ public sealed class AiApi(HttpClient http) : IAiApi
|
||||
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) =>
|
||||
Send<AiStatusDto>(() => http.GetAsync(AiApiRoutes.Status, ct), ct);
|
||||
|
||||
public Task<ApiResult<AiModelListDto>> GetModelsAsync(AiModelListRequest request, CancellationToken ct = default) =>
|
||||
Send<AiModelListDto>(() => http.PostAsJsonAsync(AiApiRoutes.Models, request, 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)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
|
||||
namespace FamilyPlanner.Web.Client.Services;
|
||||
@@ -17,6 +18,17 @@ public interface IChatApi
|
||||
/// <summary>Der eigene KI-Kanal (wird beim ersten Aufruf angelegt).</summary>
|
||||
Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Anbieter, Modell und System-Prompt des eigenen KI-Kanals (nur Erwachsene). Standardimplementierungen für Testdoubles.</summary>
|
||||
Task<ApiResult<ChatAiSettingsDto>> GetAiSettingsAsync(Guid channelId, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<ChatAiSettingsDto>.Fail(ApiProblem.Offline));
|
||||
|
||||
Task<ApiResult<ChatAiSettingsDto>> SaveAiSettingsAsync(Guid channelId, SaveChatAiSettingsRequest request, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<ChatAiSettingsDto>.Fail(ApiProblem.Offline));
|
||||
|
||||
/// <summary>Modelle des Anbieters (<paramref name="providerId"/> <c>null</c> = der dem KI-Chat zugeordnete).</summary>
|
||||
Task<ApiResult<AiModelListDto>> GetAiModelsAsync(Guid channelId, Guid? providerId, CancellationToken ct = default) =>
|
||||
Task.FromResult(ApiResult<AiModelListDto>.Ok(new AiModelListDto(false, [])));
|
||||
|
||||
Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<ChatMessageDto>> SendGifAsync(Guid channelId, SendGifRequest request, CancellationToken ct = default);
|
||||
@@ -53,6 +65,16 @@ public sealed class ChatApi(HttpClient http) : IChatApi
|
||||
public Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatChannelDto>(() => http.PostAsync($"{Base}/ai", null, ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatAiSettingsDto>> GetAiSettingsAsync(Guid channelId, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatAiSettingsDto>(() => http.GetAsync($"{Base}/channels/{channelId}/ai-settings", ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatAiSettingsDto>> SaveAiSettingsAsync(Guid channelId, SaveChatAiSettingsRequest request, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatAiSettingsDto>(() => http.PutAsJsonAsync($"{Base}/channels/{channelId}/ai-settings", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<AiModelListDto>> GetAiModelsAsync(Guid channelId, Guid? providerId, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<AiModelListDto>(
|
||||
() => http.GetAsync($"{Base}/channels/{channelId}/ai-models{(providerId is { } id ? $"?providerId={id}" : string.Empty)}", ct), ct);
|
||||
|
||||
public Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<GifSearchResultDto>(() => http.GetAsync($"{Base}/gifs?q={Uri.EscapeDataString(query)}", ct), ct);
|
||||
|
||||
|
||||
@@ -1369,6 +1369,14 @@ button.fp-meal-dish {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fp-chat-ai-note {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.85;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fp-chat-meta {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -172,6 +172,116 @@ public sealed class ChatExtrasTests(AppFixture fixture)
|
||||
|
||||
// ---------- KI-Kanal: Gespräch ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_provider_is_replaced_by_the_next_one_and_the_switch_is_shown_on_the_answer()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var _m = mom;
|
||||
await using var broken = FakeAiServer.Start();
|
||||
await using var working = FakeAiServer.Start();
|
||||
broken.Respond = _ => (500, "{}", TimeSpan.Zero);
|
||||
working.Respond = _ => (200, OllamaReply("Antwort vom Ersatz."), TimeSpan.Zero);
|
||||
var original = await SetAiChatAsync(admin, enabled: true);
|
||||
try
|
||||
{
|
||||
await ClearProvidersAsync(admin);
|
||||
foreach (var (name, url, purposes) in new[] { ("Kaputt", broken.BaseUrl, AiPurpose.ChatAssistant), ("Ersatz", working.BaseUrl, AiPurpose.Default) })
|
||||
{
|
||||
var created = await admin.PostAsJsonAsync("/api/admin/ai/providers",
|
||||
new SaveAiProviderRequest(name, AiProviderType.Ollama, url, "fake-model", null, false, 30, true, false, purposes, 0), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
}
|
||||
|
||||
var channel = await StartAiAsync(mom);
|
||||
Assert.Equal(HttpStatusCode.Created, (await SendRawAsync(mom, channel.Id, "Hallo?")).StatusCode);
|
||||
|
||||
var answer = (await PageAsync(mom, channel.Id)).Messages.Last();
|
||||
Assert.Equal("Antwort vom Ersatz.", answer.Text);
|
||||
Assert.Contains("Kaputt", answer.AiNote);
|
||||
Assert.Contains("Ersatz", answer.AiNote);
|
||||
Assert.NotEmpty(broken.Requests);
|
||||
Assert.NotEmpty(working.Requests);
|
||||
|
||||
// Ohne Ausfall gibt es keinen Hinweis
|
||||
broken.Respond = _ => (200, OllamaReply("Alles gut."), TimeSpan.Zero);
|
||||
await SendRawAsync(mom, channel.Id, "Noch eine Frage");
|
||||
Assert.Null((await PageAsync(mom, channel.Id)).Messages.Last().AiNote);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
|
||||
await ClearProvidersAsync(admin);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Adults_can_choose_provider_model_and_prompt_for_their_ai_channel_children_cannot()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
using var _m = mom;
|
||||
using var _k = kid;
|
||||
await using var chat = FakeAiServer.Start();
|
||||
await using var other = FakeAiServer.Start();
|
||||
chat.Respond = _ => (200, OllamaReply("vom Chat-Anbieter"), TimeSpan.Zero);
|
||||
other.Respond = request => request.Path.EndsWith("/api/tags", StringComparison.Ordinal)
|
||||
? (200, """{"models":[{"name":"zeta"},{"name":"alpha"}]}""", TimeSpan.Zero)
|
||||
: (200, OllamaReply("vom gewählten Anbieter"), TimeSpan.Zero);
|
||||
var original = await SetAiChatAsync(admin, enabled: true, children: true);
|
||||
try
|
||||
{
|
||||
await ClearProvidersAsync(admin);
|
||||
AiProviderDto otherProvider = null!;
|
||||
foreach (var (name, url, purposes) in new[] { ("Chat", chat.BaseUrl, AiPurpose.ChatAssistant), ("Anderer", other.BaseUrl, AiPurpose.None) })
|
||||
{
|
||||
var created = await admin.PostAsJsonAsync("/api/admin/ai/providers",
|
||||
new SaveAiProviderRequest(name, AiProviderType.Ollama, url, "fake-model", null, false, 30, true, false, purposes, 0), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
if (name == "Anderer")
|
||||
{
|
||||
otherProvider = (await created.Content.ReadFromJsonAsync<AiProviderDto>(Ct))!;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True((await FeaturesAsync(mom)).CanCustomizeAi);
|
||||
Assert.False((await FeaturesAsync(kid)).CanCustomizeAi);
|
||||
var kidChannel = await StartAiAsync(kid);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/chat/channels/{kidChannel.Id}/ai-settings", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await kid.PutAsJsonAsync($"/api/chat/channels/{kidChannel.Id}/ai-settings", new SaveChatAiSettingsRequest(otherProvider.Id, "x", "Böse"), Ct)).StatusCode);
|
||||
|
||||
var channel = await StartAiAsync(mom);
|
||||
var settings = (await mom.GetFromJsonAsync<ChatAiSettingsDto>($"/api/chat/channels/{channel.Id}/ai-settings", Ct))!;
|
||||
Assert.Null(settings.ProviderId);
|
||||
Assert.Contains(settings.Providers, p => p.Name == "Anderer");
|
||||
|
||||
var models = (await mom.GetFromJsonAsync<AiModelListDto>($"/api/chat/channels/{channel.Id}/ai-models?providerId={otherProvider.Id}", Ct))!;
|
||||
Assert.Equal(["alpha", "zeta"], models.Models);
|
||||
|
||||
var saved = await mom.PutAsJsonAsync($"/api/chat/channels/{channel.Id}/ai-settings", new SaveChatAiSettingsRequest(otherProvider.Id, "alpha", "Antworte wie ein Pirat."), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Created, (await SendRawAsync(mom, channel.Id, "Ahoi")).StatusCode);
|
||||
|
||||
Assert.Equal("vom gewählten Anbieter", (await PageAsync(mom, channel.Id)).Messages.Last().Text);
|
||||
Assert.Empty(chat.Requests);
|
||||
var body = other.Requests.Last(r => !r.Path.EndsWith("/api/tags", StringComparison.Ordinal)).Body ?? string.Empty;
|
||||
Assert.Contains("\"alpha\"", body);
|
||||
Assert.Contains("Pirat", body);
|
||||
Assert.DoesNotContain("freundlicher Assistent", body);
|
||||
|
||||
// Unbekannter Anbieter wird abgelehnt
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await mom.PutAsJsonAsync($"/api/chat/channels/{channel.Id}/ai-settings", new SaveChatAiSettingsRequest(Guid.NewGuid(), null, null), Ct)).StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
|
||||
await ClearProvidersAsync(admin);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Question_is_saved_and_answered_by_the_ai_with_a_fixed_prompt_context_and_a_log_without_content()
|
||||
{
|
||||
|
||||
@@ -65,6 +65,49 @@ public sealed class AiAdapterTests
|
||||
Assert.Equal((11, 4), (response.InputTokens, response.OutputTokens));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ollama_lists_installed_models_sorted()
|
||||
{
|
||||
var (provider, handler) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, """{"models":[{"name":"qwen3:8b"},{"name":"llama3.1:latest"},{"model":"x"}]}""");
|
||||
|
||||
var models = await ((IAiProvider)provider).ListModelsAsync(new AiProviderSettings(AiProviderType.Ollama, "http://nas:11434/", string.Empty, null), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("http://nas:11434/api/tags", Assert.Single(handler.Calls).Request.RequestUri!.ToString());
|
||||
Assert.Equal(["llama3.1:latest", "qwen3:8b"], models);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Providers_without_a_model_list_return_null()
|
||||
{
|
||||
var (provider, _) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, "{}");
|
||||
|
||||
Assert.Null(await ((IAiProvider)provider).ListModelsAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", string.Empty, "k"), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Google_sends_generate_content_with_key_header_images_and_schema()
|
||||
{
|
||||
var (provider, handler) = Make(f => new GoogleProvider(f), HttpStatusCode.OK,
|
||||
"""{"candidates":[{"content":{"parts":[{"text":"{\"items\":[1]}"}]}}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":3}}""");
|
||||
var request = new AiRequest("Sei Lehrer.", [AiMessage.User("Was siehst du?", Image), new AiMessage("assistant", "Ein Bild."), AiMessage.User("Danke")], Schema, 400);
|
||||
|
||||
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Google, "https://generativelanguage.googleapis.com/", "gemini-2.5-flash", "KEY"), request, TestContext.Current.CancellationToken);
|
||||
|
||||
var (sent, body) = Assert.Single(handler.Calls);
|
||||
Assert.Equal("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", sent.RequestUri!.ToString());
|
||||
Assert.Equal("KEY", Assert.Single(sent.Headers.GetValues("x-goog-api-key")));
|
||||
using var json = Json(body);
|
||||
var root = json.RootElement;
|
||||
Assert.Equal("Sei Lehrer.", root.GetProperty("systemInstruction").GetProperty("parts")[0].GetProperty("text").GetString());
|
||||
Assert.Equal(400, root.GetProperty("generationConfig").GetProperty("maxOutputTokens").GetInt32());
|
||||
Assert.Equal("application/json", root.GetProperty("generationConfig").GetProperty("responseMimeType").GetString());
|
||||
var contents = root.GetProperty("contents");
|
||||
Assert.Equal(["user", "model", "user"], contents.EnumerateArray().Select(c => c.GetProperty("role").GetString()));
|
||||
Assert.Equal(Convert.ToBase64String([1, 2, 3]), contents[0].GetProperty("parts")[0].GetProperty("inlineData").GetProperty("data").GetString());
|
||||
Assert.Equal(1, response.Json!.Value.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal((7, 3), (response.InputTokens, response.OutputTokens));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ollama_sends_bearer_only_when_a_key_is_configured_and_plain_text_has_no_json()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user