Implement portrait generation and retrieval for people in the school system. Added new API endpoints for generating and fetching portraits, including support for avatar and full-body images. Updated the client-side to handle portrait states and display options. Enhanced the person card UI to include tabs for overview and portrait, with appropriate localization strings. Updated solution files to include new test projects. Adjusted server configuration for SwarmUI integration.
ci / server (push) Failing after 3m46s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 04:14:41 +03:00
parent 871178909e
commit dfa9a25dca
32 changed files with 1538 additions and 121 deletions
+138
View File
@@ -0,0 +1,138 @@
using HSchool.Server.Api;
namespace HSchool.Server.Game;
internal sealed class PortraitService(
SchoolStore store,
SwarmUiClient swarm,
SwarmUiSettings settings,
GameCommandQueue commands,
ILogger<PortraitService> logger)
{
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
private const string PromptLocale = "en";
public bool IsGenerationEnabled => swarm.IsConfigured;
public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) =>
store.PortraitFlags(schoolId, personId);
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
{
var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody };
}
public async Task<PersonLookupError> EnsurePersonAsync(
int schoolId,
string personId,
CancellationToken cancellationToken)
{
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
return outcome.Error;
}
public async Task<PortraitGenerationResult> GenerateAsync(
int schoolId,
string personId,
PortraitKind kind,
CancellationToken cancellationToken)
{
if (!swarm.IsConfigured)
{
return PortraitGenerationResult.NotConfigured;
}
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson)
{
return PortraitGenerationResult.UnknownPerson;
}
if (outcome.Error != PersonLookupError.None || outcome.Card is null)
{
return PortraitGenerationResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind);
try
{
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
store.SavePortrait(schoolId, personId, kind, bytes);
var flags = Flags(schoolId, personId);
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasFullBody);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.TimedOut;
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.Unavailable;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.Unavailable;
}
}
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
internal enum PortraitGenerationOutcome
{
Succeeded,
UnknownSchool,
UnknownPerson,
NotConfigured,
Unavailable,
TimedOut,
}
internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome,
PortraitKind Kind,
bool HasAvatar,
bool HasFullBody)
{
public static PortraitGenerationResult UnknownSchool { get; } =
new(PortraitGenerationOutcome.UnknownSchool, default, false, false);
public static PortraitGenerationResult UnknownPerson { get; } =
new(PortraitGenerationOutcome.UnknownPerson, default, false, false);
public static PortraitGenerationResult NotConfigured { get; } =
new(PortraitGenerationOutcome.NotConfigured, default, false, false);
public static PortraitGenerationResult Unavailable { get; } =
new(PortraitGenerationOutcome.Unavailable, default, false, false);
public static PortraitGenerationResult TimedOut { get; } =
new(PortraitGenerationOutcome.TimedOut, default, false, false);
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody);
}
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody);