Implement portrait prompt API and UI enhancements. Added a new endpoint to fetch portrait prompts, updated the client to handle prompt display, and improved localization for prompt-related text. Enhanced styling for prompt elements in the UI.
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 15s

This commit is contained in:
Leonid Pershin
2026-08-20 05:27:01 +03:00
parent a214378c6e
commit 3fa6ce95df
13 changed files with 401 additions and 34 deletions
+35
View File
@@ -270,6 +270,41 @@ internal static class SchoolEndpoints
})
.WithName("GetSchoolPersonPortrait");
schools.MapGet("/{id:int}/people/{personId}/portrait/prompt", async (
int id,
string personId,
string? kind,
string? promptExtra,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar, custom, or full.");
}
var result = await portraits.BuildPromptAsync(id, personId, portraitKind, promptExtra, cancellationToken);
return result.Outcome switch
{
PortraitPromptBuildOutcome.Succeeded => Results.Ok(new PortraitPromptResponse(
PortraitKindParser.ToApiValue(result.Kind),
result.Positive,
result.Negative,
result.PromptExtra)),
PortraitPromptBuildOutcome.InvalidPrompt =>
Problem(StatusCodes.Status400BadRequest, "invalid-body", "Custom portraits need a non-empty promptExtra up to 2000 characters."),
PortraitPromptBuildOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPersonPortraitPrompt");
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
int id,
string personId,
+106 -8
View File
@@ -50,6 +50,38 @@ internal sealed class PortraitService(
return outcome.Error;
}
public async Task<PortraitPromptBuildResult> BuildPromptAsync(
int schoolId,
string personId,
PortraitKind kind,
string? promptExtra,
CancellationToken cancellationToken)
{
var resolved = ResolveCustomPromptExtra(schoolId, personId, kind, promptExtra);
if (kind == PortraitKind.Custom && resolved is null)
{
return PortraitPromptBuildResult.InvalidPrompt;
}
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson)
{
return PortraitPromptBuildResult.UnknownPerson;
}
if (outcome.Error != PersonLookupError.None || outcome.Card is null)
{
return PortraitPromptBuildResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind, resolved);
return PortraitPromptBuildResult.Succeeded(
kind,
positive,
negative,
kind == PortraitKind.Custom ? resolved : null);
}
public async Task<PortraitGenerationResult> GenerateAsync(
int schoolId,
string personId,
@@ -76,14 +108,7 @@ internal sealed class PortraitService(
}
}
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson)
{
return PortraitGenerationResult.UnknownPerson;
@@ -126,10 +151,83 @@ internal sealed class PortraitService(
}
}
private async Task<PersonCardResult> LookupPersonAsync(
int schoolId,
string personId,
CancellationToken cancellationToken)
{
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
return await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
}
private string? ResolveCustomPromptExtra(int schoolId, string personId, PortraitKind kind, string? promptExtra)
{
if (kind != PortraitKind.Custom)
{
return null;
}
promptExtra = promptExtra?.Trim();
if (string.IsNullOrWhiteSpace(promptExtra))
{
promptExtra = store.TryReadCustomPortraitPrompt(schoolId, personId)?.Trim();
}
if (string.IsNullOrWhiteSpace(promptExtra) || promptExtra.Length > MaxCustomPromptLength)
{
return null;
}
return promptExtra;
}
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
internal enum PortraitPromptBuildOutcome
{
Succeeded,
UnknownSchool,
UnknownPerson,
InvalidPrompt,
}
internal sealed record PortraitPromptBuildResult(
PortraitPromptBuildOutcome Outcome,
PortraitKind Kind,
string Positive,
string Negative,
string? PromptExtra)
{
public static PortraitPromptBuildResult UnknownSchool { get; } =
new(PortraitPromptBuildOutcome.UnknownSchool, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult UnknownPerson { get; } =
new(PortraitPromptBuildOutcome.UnknownPerson, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult InvalidPrompt { get; } =
new(PortraitPromptBuildOutcome.InvalidPrompt, default, string.Empty, string.Empty, null);
public static PortraitPromptBuildResult Succeeded(
PortraitKind kind,
string positive,
string negative,
string? promptExtra) =>
new(PortraitPromptBuildOutcome.Succeeded, kind, positive, negative, promptExtra);
}
internal sealed record PortraitPromptResponse(
string Kind,
string Positive,
string Negative,
string? PromptExtra);
internal enum PortraitGenerationOutcome
{
Succeeded,
+25 -25
View File
@@ -1,25 +1,25 @@
{
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"steps": 4,
"cfgScale": 2,
"clipSkip": 2,
"sampler": "dpmpp_sde",
"scheduler": "karras",
"seed": 3346112079,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
},
"custom": {
"width": 896,
"height": 1152
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
}
}
{
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
"steps": 4,
"cfgScale": 2,
"clipSkip": 2,
"sampler": "dpmpp_sde",
"scheduler": "karras",
"seed": 3346112079,
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
"avatar": {
"width": 1024,
"height": 1024,
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
},
"custom": {
"width": 896,
"height": 1152
},
"fullBody": {
"width": 896,
"height": 1152,
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
}
}