From 3fa6ce95df1a254941da5197d1421132a339ea2e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 05:27:01 +0300 Subject: [PATCH] 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. --- docs/protocol.md | 17 +++ src/HSchool.Client/package-lock.json | 7 ++ src/HSchool.Client/package.json | 3 + src/HSchool.Client/src/i18n/strings.ts | 12 ++ src/HSchool.Client/src/net/api.ts | 23 ++++ src/HSchool.Client/src/style.css | 36 ++++++ src/HSchool.Client/src/ui/personCard.test.ts | 1 + src/HSchool.Client/src/ui/personCard.ts | 62 +++++++++- src/HSchool.Client/src/ui/personCardHost.ts | 53 ++++++++ src/HSchool.Server/Api/SchoolEndpoints.cs | 35 ++++++ src/HSchool.Server/Game/PortraitService.cs | 114 ++++++++++++++++-- src/HSchool.Server/swarmui.json | 50 ++++---- .../HSchool.AppHost.Tests/PortraitApiTests.cs | 22 ++++ 13 files changed, 401 insertions(+), 34 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index eb5c8cc..69b866d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -376,6 +376,23 @@ at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swa Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files land under `saves/{id}.portraits/` and survive until the school is deleted. +### `GET /api/schools/{id}/people/{personId}/portrait/prompt` + +Returns the positive and negative prompts SwarmUI would receive, without generating an image. +Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended to +the base prompt; when omitted, the last saved custom prompt is used if one exists. Unknown school +is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid `kind` is +`400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`. + +```json +{ + "kind": "avatar", + "positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …", + "negative": "(low quality, worst quality:1.4), …", + "promptExtra": null +} +``` + `GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured) so the client can disable generate buttons and show reachability without trying POST first. diff --git a/src/HSchool.Client/package-lock.json b/src/HSchool.Client/package-lock.json index a3a300a..8295d2b 100644 --- a/src/HSchool.Client/package-lock.json +++ b/src/HSchool.Client/package-lock.json @@ -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", diff --git a/src/HSchool.Client/package.json b/src/HSchool.Client/package.json index ac695e9..de127c5 100644 --- a/src/HSchool.Client/package.json +++ b/src/HSchool.Client/package.json @@ -20,5 +20,8 @@ "typescript": "~5.9.3", "vite": "^8.2.1", "vitest": "^4.1.10" + }, + "dependencies": { + "hschool-client": "file:" } } diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 98ce39a..dca2acd 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index bd7aec8..a6b8965 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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 { + const params = new URLSearchParams({ kind }); + if (kind === 'custom' && promptExtra !== undefined && promptExtra.length > 0) { + params.set('promptExtra', promptExtra); + } + + return request( + `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait/prompt?${params.toString()}`, + ); +} + export async function generatePortrait( schoolId: number, personId: string, diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index d795d7a..5fd3a0d 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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; diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 4218bd1..920b7d0 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -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); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 5c4b65f..21132ff 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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>; + 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; diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 26cdd0d..5bf4850 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -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> = {}; + 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 { + 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 { const schoolId = this.schoolId; const personId = this.painted?.id; diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 36a244a..0626b5f 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -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, diff --git a/src/HSchool.Server/Game/PortraitService.cs b/src/HSchool.Server/Game/PortraitService.cs index 8af4ae0..1063fcb 100644 --- a/src/HSchool.Server/Game/PortraitService.cs +++ b/src/HSchool.Server/Game/PortraitService.cs @@ -50,6 +50,38 @@ internal sealed class PortraitService( return outcome.Error; } + public async Task 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 GenerateAsync( int schoolId, string personId, @@ -76,14 +108,7 @@ internal sealed class PortraitService( } } - var command = new GameCommand.GetPerson( - schoolId, - personId, - PromptLocale, - NewCompletion()); - 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 LookupPersonAsync( + int schoolId, + string personId, + CancellationToken cancellationToken) + { + var command = new GameCommand.GetPerson( + schoolId, + personId, + PromptLocale, + NewCompletion()); + 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 NewCompletion() => 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, diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index f6bd2d2..9a900f2 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -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." + } +} diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 7f37282..3dd7ab9 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -68,6 +68,26 @@ public class PortraitApiTests(AppHostFixture fixture) Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response)); } + [Fact] + public async Task GetPortraitPrompt_ReturnsBuiltPositiveAndNegative() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Промпт", Start); + var personId = await FirstPersonIdAsync(client, school.Id); + + using var response = await client.GetAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait/prompt?kind=avatar", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(payload); + Assert.Equal("avatar", payload!.Kind); + Assert.Contains("age", payload.Positive, StringComparison.OrdinalIgnoreCase); + Assert.False(string.IsNullOrWhiteSpace(payload.Negative)); + } + [Fact] public async Task DeleteSchool_RemovesPortraitDirectory() { @@ -114,5 +134,7 @@ public class PortraitApiTests(AppHostFixture fixture) bool HasFullBody, string? CustomPortraitPrompt); + private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra); + private sealed record SavesDirectoryResponse(string Path); }