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
|
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.
|
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)
|
`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.
|
so the client can disable generate buttons and show reachability without trying POST first.
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "hschool-client",
|
"name": "hschool-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"hschool-client": "file:"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"happy-dom": "^20.11.2",
|
"happy-dom": "^20.11.2",
|
||||||
@@ -594,6 +597,10 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hschool-client": {
|
||||||
|
"resolved": "",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
|||||||
@@ -20,5 +20,8 @@
|
|||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.2.1",
|
"vite": "^8.2.1",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hschool-client": "file:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,12 @@ const ru = {
|
|||||||
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
|
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
|
||||||
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
|
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
|
||||||
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
|
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
|
||||||
|
peoplePortraitShowPrompt: 'Показать итоговый промпт',
|
||||||
|
peoplePortraitHidePrompt: 'Скрыть промпт',
|
||||||
|
peoplePortraitPromptPositive: 'Positive',
|
||||||
|
peoplePortraitPromptNegative: 'Negative',
|
||||||
|
peoplePortraitPromptLoading: 'Сборка промпта…',
|
||||||
|
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
|
||||||
|
|
||||||
modeOverview: 'Обзор',
|
modeOverview: 'Обзор',
|
||||||
modeManage: 'Управление',
|
modeManage: 'Управление',
|
||||||
@@ -432,6 +438,12 @@ const en: Messages = {
|
|||||||
peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable',
|
peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable',
|
||||||
peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.',
|
peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.',
|
||||||
peoplePortraitFailed: 'Could not generate the portrait.',
|
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',
|
modeOverview: 'Overview',
|
||||||
modeManage: 'Management',
|
modeManage: 'Management',
|
||||||
|
|||||||
@@ -425,6 +425,29 @@ export function portraitUrl(
|
|||||||
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
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(
|
export async function generatePortrait(
|
||||||
schoolId: number,
|
schoolId: number,
|
||||||
personId: string,
|
personId: string,
|
||||||
|
|||||||
@@ -651,6 +651,42 @@ body {
|
|||||||
margin-bottom: 8px;
|
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 {
|
.people__swarm-status {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ describe('renderPersonCard', () => {
|
|||||||
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
|
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
|
||||||
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
|
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
|
||||||
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
|
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
|
||||||
|
expect(panel?.textContent).toContain(t('peoplePortraitShowPrompt'));
|
||||||
expect(panel?.hasAttribute('hidden')).toBe(false);
|
expect(panel?.hasAttribute('hidden')).toBe(false);
|
||||||
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
|
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PersonRel,
|
PersonRel,
|
||||||
PersonRole,
|
PersonRole,
|
||||||
} from '../net/api.ts';
|
} 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 { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||||
import { el } from './dom.ts';
|
import { el } from './dom.ts';
|
||||||
@@ -37,6 +37,11 @@ export interface RenderPersonCardOptions {
|
|||||||
readonly customPrompt?: string;
|
readonly customPrompt?: string;
|
||||||
readonly onCustomPromptChange?: (value: string) => void;
|
readonly onCustomPromptChange?: (value: string) => void;
|
||||||
readonly customPortraitRevision?: number;
|
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;
|
readonly swarmConfigured?: boolean;
|
||||||
/** null while checking or when SwarmUI is not configured. */
|
/** null while checking or when SwarmUI is not configured. */
|
||||||
readonly swarmConnected?: boolean | null;
|
readonly swarmConnected?: boolean | null;
|
||||||
@@ -358,6 +363,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
|||||||
onClick: () => options.onGeneratePortrait?.(variant.kind),
|
onClick: () => options.onGeneratePortrait?.(variant.kind),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
parent.append(portraitPromptView(variant.kind, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
|
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),
|
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) {
|
if (options.swarmConfigured === false) {
|
||||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
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) {
|
if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
|
||||||
parent.append(el('p', { class: 'panel__error', text: portraitError }));
|
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 {
|
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 {
|
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
|
||||||
if (options.swarmConfigured !== true) {
|
if (options.swarmConfigured !== true) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import {
|
|||||||
fetchGameStatus,
|
fetchGameStatus,
|
||||||
fetchPerson,
|
fetchPerson,
|
||||||
fetchPersonLog,
|
fetchPersonLog,
|
||||||
|
fetchPortraitPrompt,
|
||||||
generatePortrait,
|
generatePortrait,
|
||||||
type PersonCard,
|
type PersonCard,
|
||||||
type PersonLogDir,
|
type PersonLogDir,
|
||||||
type PersonLogPage,
|
type PersonLogPage,
|
||||||
type PersonLogQuery,
|
type PersonLogQuery,
|
||||||
type PortraitKind,
|
type PortraitKind,
|
||||||
|
type PortraitPrompt,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
import { getLocale } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
@@ -28,6 +30,10 @@ export class PersonCardHost {
|
|||||||
private customPromptDraft = '';
|
private customPromptDraft = '';
|
||||||
private customPortraitRevision = 0;
|
private customPortraitRevision = 0;
|
||||||
private paintedId: string | null = null;
|
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 swarmConfigured = false;
|
||||||
private swarmConnected: boolean | null = null;
|
private swarmConnected: boolean | null = null;
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
@@ -69,6 +75,10 @@ export class PersonCardHost {
|
|||||||
this.customPromptDraft = '';
|
this.customPromptDraft = '';
|
||||||
this.customPortraitRevision = 0;
|
this.customPortraitRevision = 0;
|
||||||
this.paintedId = null;
|
this.paintedId = null;
|
||||||
|
this.portraitPromptOpen = null;
|
||||||
|
this.portraitPrompts = {};
|
||||||
|
this.portraitPromptLoading = null;
|
||||||
|
this.portraitPromptError = null;
|
||||||
this.swarmConnected = null;
|
this.swarmConnected = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +111,9 @@ export class PersonCardHost {
|
|||||||
this.paintedId = card.id;
|
this.paintedId = card.id;
|
||||||
this.customPromptDraft = card.customPortraitPrompt ?? '';
|
this.customPromptDraft = card.customPortraitPrompt ?? '';
|
||||||
this.customPortraitRevision = 0;
|
this.customPortraitRevision = 0;
|
||||||
|
this.portraitPromptOpen = null;
|
||||||
|
this.portraitPrompts = {};
|
||||||
|
this.portraitPromptError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.repaint(container, card);
|
this.repaint(container, card);
|
||||||
@@ -150,9 +163,19 @@ export class PersonCardHost {
|
|||||||
customPrompt: this.customPromptDraft,
|
customPrompt: this.customPromptDraft,
|
||||||
onCustomPromptChange: (value) => {
|
onCustomPromptChange: (value) => {
|
||||||
this.customPromptDraft = value;
|
this.customPromptDraft = value;
|
||||||
|
if (this.portraitPromptOpen === 'custom') {
|
||||||
|
this.portraitPromptOpen = null;
|
||||||
|
delete this.portraitPrompts.custom;
|
||||||
|
}
|
||||||
|
|
||||||
this.refreshPainted();
|
this.refreshPainted();
|
||||||
},
|
},
|
||||||
customPortraitRevision: this.customPortraitRevision,
|
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,
|
swarmConfigured: this.swarmConfigured,
|
||||||
swarmConnected: this.swarmConnected,
|
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> {
|
private async generate(kind: PortraitKind, promptExtra?: string): Promise<void> {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
const personId = this.painted?.id;
|
const personId = this.painted?.id;
|
||||||
|
|||||||
@@ -270,6 +270,41 @@ internal static class SchoolEndpoints
|
|||||||
})
|
})
|
||||||
.WithName("GetSchoolPersonPortrait");
|
.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 (
|
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
|
||||||
int id,
|
int id,
|
||||||
string personId,
|
string personId,
|
||||||
|
|||||||
@@ -50,6 +50,38 @@ internal sealed class PortraitService(
|
|||||||
return outcome.Error;
|
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(
|
public async Task<PortraitGenerationResult> GenerateAsync(
|
||||||
int schoolId,
|
int schoolId,
|
||||||
string personId,
|
string personId,
|
||||||
@@ -76,14 +108,7 @@ internal sealed class PortraitService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var command = new GameCommand.GetPerson(
|
var outcome = await LookupPersonAsync(schoolId, personId, cancellationToken);
|
||||||
schoolId,
|
|
||||||
personId,
|
|
||||||
PromptLocale,
|
|
||||||
NewCompletion<PersonCardResult>());
|
|
||||||
commands.Enqueue(command);
|
|
||||||
|
|
||||||
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
|
|
||||||
if (outcome.Error == PersonLookupError.UnknownPerson)
|
if (outcome.Error == PersonLookupError.UnknownPerson)
|
||||||
{
|
{
|
||||||
return PortraitGenerationResult.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>() =>
|
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
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
|
internal enum PortraitGenerationOutcome
|
||||||
{
|
{
|
||||||
Succeeded,
|
Succeeded,
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
"model": "DreamShaper_XL_-_Lightning_DPM++_SDE.safetensors",
|
||||||
"steps": 4,
|
"steps": 4,
|
||||||
"cfgScale": 2,
|
"cfgScale": 2,
|
||||||
"clipSkip": 2,
|
"clipSkip": 2,
|
||||||
"sampler": "dpmpp_sde",
|
"sampler": "dpmpp_sde",
|
||||||
"scheduler": "karras",
|
"scheduler": "karras",
|
||||||
"seed": 3346112079,
|
"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",
|
"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": {
|
"avatar": {
|
||||||
"width": 1024,
|
"width": 1024,
|
||||||
"height": 1024,
|
"height": 1024,
|
||||||
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
"positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
"width": 896,
|
"width": 896,
|
||||||
"height": 1152
|
"height": 1152
|
||||||
},
|
},
|
||||||
"fullBody": {
|
"fullBody": {
|
||||||
"width": 896,
|
"width": 896,
|
||||||
"height": 1152,
|
"height": 1152,
|
||||||
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
"positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ public class PortraitApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response));
|
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]
|
[Fact]
|
||||||
public async Task DeleteSchool_RemovesPortraitDirectory()
|
public async Task DeleteSchool_RemovesPortraitDirectory()
|
||||||
{
|
{
|
||||||
@@ -114,5 +134,7 @@ public class PortraitApiTests(AppHostFixture fixture)
|
|||||||
bool HasFullBody,
|
bool HasFullBody,
|
||||||
string? CustomPortraitPrompt);
|
string? CustomPortraitPrompt);
|
||||||
|
|
||||||
|
private sealed record PortraitPromptPayload(string Kind, string Positive, string Negative, string? PromptExtra);
|
||||||
|
|
||||||
private sealed record SavesDirectoryResponse(string Path);
|
private sealed record SavesDirectoryResponse(string Path);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user