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
+7
View File
@@ -7,6 +7,9 @@
"": {
"name": "hschool-client",
"version": "0.1.0",
"dependencies": {
"hschool-client": "file:"
},
"devDependencies": {
"@types/node": "^24.10.1",
"happy-dom": "^20.11.2",
@@ -594,6 +597,10 @@
"node": ">=20.0.0"
}
},
"node_modules/hschool-client": {
"resolved": "",
"link": true
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+3
View File
@@ -20,5 +20,8 @@
"typescript": "~5.9.3",
"vite": "^8.2.1",
"vitest": "^4.1.10"
},
"dependencies": {
"hschool-client": "file:"
}
}
+12
View File
@@ -172,6 +172,12 @@ const ru = {
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
peoplePortraitShowPrompt: 'Показать итоговый промпт',
peoplePortraitHidePrompt: 'Скрыть промпт',
peoplePortraitPromptPositive: 'Positive',
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Сборка промпта…',
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
modeOverview: 'Обзор',
modeManage: 'Управление',
@@ -432,6 +438,12 @@ const en: Messages = {
peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable',
peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.',
peoplePortraitFailed: 'Could not generate the portrait.',
peoplePortraitShowPrompt: 'Show final prompt',
peoplePortraitHidePrompt: 'Hide prompt',
peoplePortraitPromptPositive: 'Positive',
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Building prompt…',
peoplePortraitPromptFailed: 'Could not load the prompt.',
modeOverview: 'Overview',
modeManage: 'Management',
+23
View File
@@ -425,6 +425,29 @@ export function portraitUrl(
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
}
export interface PortraitPrompt {
readonly kind: PortraitKind;
readonly positive: string;
readonly negative: string;
readonly promptExtra: string | null;
}
export async function fetchPortraitPrompt(
schoolId: number,
personId: string,
kind: PortraitKind,
promptExtra?: string,
): Promise<PortraitPrompt> {
const params = new URLSearchParams({ kind });
if (kind === 'custom' && promptExtra !== undefined && promptExtra.length > 0) {
params.set('promptExtra', promptExtra);
}
return request<PortraitPrompt>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait/prompt?${params.toString()}`,
);
}
export async function generatePortrait(
schoolId: number,
personId: string,
+36
View File
@@ -651,6 +651,42 @@ body {
margin-bottom: 8px;
}
.people__portrait-prompt-wrap {
margin: -8px 0 16px;
}
.people__portrait-prompt-toggle {
margin-bottom: 8px;
}
.people__portrait-prompt-loading {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-muted, #666);
}
.people__portrait-prompt-view {
margin-bottom: 8px;
}
.people__portrait-prompt-text {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-raised);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.people__portrait-prompt-text--muted {
color: var(--text-muted, #666);
}
.people__swarm-status {
margin: 0 0 12px;
font-size: 13px;
@@ -176,6 +176,7 @@ describe('renderPersonCard', () => {
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitShowPrompt'));
expect(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
});
+61 -1
View File
@@ -7,7 +7,7 @@ import type {
PersonRel,
PersonRole,
} from '../net/api.ts';
import { portraitUrl, type PortraitKind } from '../net/api.ts';
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
@@ -37,6 +37,11 @@ export interface RenderPersonCardOptions {
readonly customPrompt?: string;
readonly onCustomPromptChange?: (value: string) => void;
readonly customPortraitRevision?: number;
readonly portraitPromptOpen?: PortraitKind | null;
readonly portraitPrompts?: Partial<Record<PortraitKind, PortraitPrompt>>;
readonly portraitPromptLoading?: PortraitKind | null;
readonly portraitPromptError?: string | null;
readonly onShowPortraitPrompt?: (kind: PortraitKind, promptExtra?: string) => void;
readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null;
@@ -358,6 +363,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
onClick: () => options.onGeneratePortrait?.(variant.kind),
}),
);
parent.append(portraitPromptView(variant.kind, options));
}
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
@@ -401,6 +407,9 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
}),
);
if (customPrompt.length > 0 || card.hasCustom) {
parent.append(portraitPromptView('custom', options, customPrompt.length > 0 ? customPrompt : undefined));
}
if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
@@ -412,6 +421,11 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
parent.append(el('p', { class: 'panel__error', text: portraitError }));
}
const promptError = options.portraitPromptError;
if (promptError !== undefined && promptError !== null && promptError.length > 0) {
parent.append(el('p', { class: 'panel__error', text: promptError }));
}
}
const PORTRAIT_VARIANTS: readonly {
@@ -437,6 +451,52 @@ const PORTRAIT_VARIANTS: readonly {
},
];
function portraitPromptView(
kind: PortraitKind,
options: RenderPersonCardOptions,
promptExtra?: string,
): HTMLElement {
const open = options.portraitPromptOpen === kind;
const loading = options.portraitPromptLoading === kind;
const prompt = options.portraitPrompts?.[kind];
const toggle = el('button', {
class: 'button button--small people__portrait-prompt-toggle',
type: 'button',
text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'),
disabled: (options.portraitPromptLoading ?? null) !== null && !loading,
onClick: () => options.onShowPortraitPrompt?.(kind, promptExtra),
});
if (!open && !loading) {
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
}
if (loading) {
return el(
'div',
{ class: 'people__portrait-prompt-wrap' },
toggle,
el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }),
);
}
if (prompt === undefined) {
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
}
return el(
'div',
{ class: 'people__portrait-prompt-wrap' },
toggle,
el('div', { class: 'people__portrait-prompt-view' },
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
el('pre', { class: 'people__portrait-prompt-text people__portrait-prompt-text--muted', text: prompt.negative }),
),
);
}
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
if (options.swarmConfigured !== true) {
return false;
@@ -3,12 +3,14 @@ import {
fetchGameStatus,
fetchPerson,
fetchPersonLog,
fetchPortraitPrompt,
generatePortrait,
type PersonCard,
type PersonLogDir,
type PersonLogPage,
type PersonLogQuery,
type PortraitKind,
type PortraitPrompt,
} from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
@@ -28,6 +30,10 @@ export class PersonCardHost {
private customPromptDraft = '';
private customPortraitRevision = 0;
private paintedId: string | null = null;
private portraitPromptOpen: PortraitKind | null = null;
private portraitPrompts: Partial<Record<PortraitKind, PortraitPrompt>> = {};
private portraitPromptLoading: PortraitKind | null = null;
private portraitPromptError: string | null = null;
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private schoolId: number | null = null;
@@ -69,6 +75,10 @@ export class PersonCardHost {
this.customPromptDraft = '';
this.customPortraitRevision = 0;
this.paintedId = null;
this.portraitPromptOpen = null;
this.portraitPrompts = {};
this.portraitPromptLoading = null;
this.portraitPromptError = null;
this.swarmConnected = null;
}
@@ -101,6 +111,9 @@ export class PersonCardHost {
this.paintedId = card.id;
this.customPromptDraft = card.customPortraitPrompt ?? '';
this.customPortraitRevision = 0;
this.portraitPromptOpen = null;
this.portraitPrompts = {};
this.portraitPromptError = null;
}
this.repaint(container, card);
@@ -150,9 +163,19 @@ export class PersonCardHost {
customPrompt: this.customPromptDraft,
onCustomPromptChange: (value) => {
this.customPromptDraft = value;
if (this.portraitPromptOpen === 'custom') {
this.portraitPromptOpen = null;
delete this.portraitPrompts.custom;
}
this.refreshPainted();
},
customPortraitRevision: this.customPortraitRevision,
portraitPromptOpen: this.portraitPromptOpen,
portraitPrompts: this.portraitPrompts,
portraitPromptLoading: this.portraitPromptLoading,
portraitPromptError: this.portraitPromptError,
onShowPortraitPrompt: (kind, promptExtra) => void this.togglePortraitPrompt(kind, promptExtra),
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
});
@@ -188,6 +211,36 @@ export class PersonCardHost {
}
}
private async togglePortraitPrompt(kind: PortraitKind, promptExtra?: string): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitPromptLoading !== null) {
return;
}
if (this.portraitPromptOpen === kind) {
this.portraitPromptOpen = null;
this.portraitPromptError = null;
this.refreshPainted();
return;
}
this.portraitPromptLoading = kind;
this.portraitPromptError = null;
this.refreshPainted();
try {
const prompt = await fetchPortraitPrompt(schoolId, personId, kind, promptExtra);
this.portraitPrompts[kind] = prompt;
this.portraitPromptOpen = kind;
} catch {
this.portraitPromptError = t('peoplePortraitPromptFailed');
} finally {
this.portraitPromptLoading = null;
this.refreshPainted();
}
}
private async generate(kind: PortraitKind, promptExtra?: string): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
+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."
}
}