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.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -20,5 +20,8 @@
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"hschool-client": "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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"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",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, censored, blurry, deformed, bad anatomy, logo",
|
||||
"avatar": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
|
||||
@@ -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<PortraitPromptPayload>(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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user