Implement custom portrait generation: update API, UI, and localization to support user-defined prompts for portraits, replacing half-body option with custom variant.
ci / server (push) Failing after 3m38s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 05:16:40 +03:00
parent df0aaad403
commit ac0470bbc4
19 changed files with 301 additions and 76 deletions
+10 -4
View File
@@ -155,11 +155,14 @@ const ru = {
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар',
peoplePortraitHalf: 'По пояс',
peoplePortraitFull: 'В полный рост',
peoplePortraitCustom: 'Свой промпт',
peoplePortraitCustomHint: 'Дополнение к базовому промпту',
peoplePortraitCustomPlaceholder: 'Например: standing in a school hallway, soft window light',
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
peoplePortraitGenerateCustom: 'Сгенерировать',
peoplePortraitRegenerateCustom: 'Перегенерировать',
peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
@@ -412,11 +415,14 @@ const en: Messages = {
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar',
peoplePortraitHalf: 'Waist up',
peoplePortraitFull: 'Full body',
peoplePortraitCustom: 'Custom prompt',
peoplePortraitCustomHint: 'Added to the base prompt',
peoplePortraitCustomPlaceholder: 'For example: standing in a school hallway, soft window light',
peoplePortraitGenerateAvatar: 'Generate avatar',
peoplePortraitGenerateHalf: 'Generate waist up',
peoplePortraitGenerateFull: 'Generate full body',
peoplePortraitGenerateCustom: 'Generate',
peoplePortraitRegenerateCustom: 'Regenerate',
peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
+22 -5
View File
@@ -298,8 +298,9 @@ export interface PersonCard {
readonly hasLocker: boolean;
readonly homeCount: number;
readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasCustom: boolean;
readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
}
export interface WornItem {
@@ -401,17 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status');
}
export type PortraitKind = 'avatar' | 'half' | 'full';
export type PortraitKind = 'avatar' | 'full' | 'custom';
export interface PortraitResult {
readonly kind: PortraitKind;
readonly hasAvatar: boolean;
readonly hasHalfBody: boolean;
readonly hasCustom: boolean;
readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
}
export function portraitUrl(schoolId: number, personId: string, kind: PortraitKind): string {
export function portraitUrl(
schoolId: number,
personId: string,
kind: PortraitKind,
cacheBust?: number,
): string {
const params = new URLSearchParams({ kind });
if (cacheBust !== undefined) {
params.set('v', String(cacheBust));
}
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
}
@@ -419,11 +429,18 @@ export async function generatePortrait(
schoolId: number,
personId: string,
kind: PortraitKind,
promptExtra?: string,
): Promise<PortraitResult> {
const params = new URLSearchParams({ kind });
const init: RequestInit = { method: 'POST' };
if (kind === 'custom') {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify({ promptExtra: promptExtra ?? '' });
}
return request<PortraitResult>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
{ method: 'POST' },
init,
);
}
+8 -1
View File
@@ -635,7 +635,7 @@ body {
}
.people__portrait--full,
.people__portrait--half {
.people__portrait--custom {
width: min(100%, 384px);
height: auto;
}
@@ -644,6 +644,13 @@ body {
margin-bottom: 16px;
}
.people__portrait-prompt-input {
width: 100%;
min-height: 72px;
resize: vertical;
margin-bottom: 8px;
}
.people__swarm-status {
margin: 0 0 12px;
font-size: 13px;
+18
View File
@@ -11,8 +11,12 @@ interface ElementOptions {
hidden?: boolean;
src?: string;
alt?: string;
placeholder?: string;
value?: string;
rows?: number;
dataset?: Record<string, string>;
onClick?: (event: Event) => void;
onInput?: (event: Event) => void;
}
export function el<K extends keyof HTMLElementTagNameMap>(
@@ -36,6 +40,20 @@ export function el<K extends keyof HTMLElementTagNameMap>(
(element as HTMLImageElement).alt = options.alt;
}
if (options.placeholder !== undefined && 'placeholder' in element) {
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
}
if (options.value !== undefined && 'value' in element) {
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
}
if (options.rows !== undefined && element instanceof HTMLTextAreaElement) {
element.rows = options.rows;
}
if (options.onInput !== undefined) element.addEventListener('input', options.onInput);
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
for (const [key, value] of Object.entries(options.dataset ?? {})) {
@@ -73,8 +73,9 @@ function personCard(): PersonCard {
hasLocker: false,
homeCount: 0,
hasAvatar: false,
hasHalfBody: false,
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
};
}
+4 -2
View File
@@ -61,8 +61,9 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasLocker: false,
homeCount: 2,
hasAvatar: false,
hasHalfBody: false,
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
...overrides,
};
}
@@ -172,8 +173,9 @@ describe('renderPersonCard', () => {
const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateHalf'));
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
expect(panel?.textContent).toContain(t('peoplePortraitCustomHint'));
expect(panel?.querySelector('.people__portrait-prompt-input')).not.toBeNull();
expect(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
});
+59 -9
View File
@@ -31,9 +31,12 @@ export interface RenderPersonCardOptions {
readonly onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void;
readonly schoolId?: number | null;
readonly onGeneratePortrait?: (kind: PortraitKind) => void;
readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void;
readonly portraitBusy?: PortraitKind | null;
readonly portraitError?: string | null;
readonly customPrompt?: string;
readonly onCustomPromptChange?: (value: string) => void;
readonly customPortraitRevision?: number;
readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null;
@@ -357,6 +360,48 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
);
}
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
parent.append(
el(
'label',
{ class: 'people__field' },
el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }),
el('textarea', {
class: 'input people__portrait-prompt-input',
rows: 3,
placeholder: t('peoplePortraitCustomPlaceholder'),
value: options.customPrompt ?? card.customPortraitPrompt ?? '',
disabled: (options.portraitBusy ?? null) !== null,
onInput: (event) => {
const target = event.target;
if (target instanceof HTMLTextAreaElement) {
options.onCustomPromptChange?.(target.value);
}
},
}),
),
);
parent.append(portraitCustomPreview(card, options));
const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim();
parent.append(
el('button', {
class: 'button button--small',
type: 'button',
text:
options.portraitBusy === 'custom'
? t('peoplePortraitGenerating')
: card.hasCustom
? t('peoplePortraitRegenerateCustom')
: t('peoplePortraitGenerateCustom'),
disabled:
(options.portraitBusy ?? null) !== null ||
!portraitGenerateEnabled(options) ||
customPrompt.length === 0,
onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
}),
);
if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
@@ -370,7 +415,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
}
const PORTRAIT_VARIANTS: readonly {
readonly kind: PortraitKind;
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
readonly titleKey: MessageKey;
readonly generateKey: MessageKey;
readonly cssClass: string;
@@ -383,13 +428,6 @@ const PORTRAIT_VARIANTS: readonly {
cssClass: 'people__portrait--avatar',
hasImage: (card) => card.hasAvatar,
},
{
kind: 'half',
titleKey: 'peoplePortraitHalf',
generateKey: 'peoplePortraitGenerateHalf',
cssClass: 'people__portrait--half',
hasImage: (card) => card.hasHalfBody,
},
{
kind: 'full',
titleKey: 'peoplePortraitFull',
@@ -439,6 +477,18 @@ function portraitPreview(
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
}
function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement {
if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) {
return el('img', {
class: 'people__portrait people__portrait--custom',
alt: card.fullName,
src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision),
});
}
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
}
function cardMeta(card: PersonCard): string {
const bits = [
roleLabels(card.roles),
+29 -4
View File
@@ -25,6 +25,9 @@ export class PersonCardHost {
private tab: PersonCardTab = 'overview';
private portraitBusy: PortraitKind | null = null;
private portraitError: string | null = null;
private customPromptDraft = '';
private customPortraitRevision = 0;
private paintedId: string | null = null;
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private schoolId: number | null = null;
@@ -63,6 +66,9 @@ export class PersonCardHost {
this.resetTabs();
this.portraitBusy = null;
this.portraitError = null;
this.customPromptDraft = '';
this.customPortraitRevision = 0;
this.paintedId = null;
this.swarmConnected = null;
}
@@ -87,9 +93,16 @@ export class PersonCardHost {
this.painted = card;
if (card === null) {
this.paintedId = null;
return;
}
if (card.id !== this.paintedId) {
this.paintedId = card.id;
this.customPromptDraft = card.customPortraitPrompt ?? '';
this.customPortraitRevision = 0;
}
this.repaint(container, card);
}
@@ -131,9 +144,15 @@ export class PersonCardHost {
void this.loadLog();
},
schoolId: this.schoolId,
onGeneratePortrait: (kind) => void this.generate(kind),
onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra),
portraitBusy: this.portraitBusy,
portraitError: this.portraitError,
customPrompt: this.customPromptDraft,
onCustomPromptChange: (value) => {
this.customPromptDraft = value;
this.refreshPainted();
},
customPortraitRevision: this.customPortraitRevision,
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
});
@@ -169,7 +188,7 @@ export class PersonCardHost {
}
}
private async generate(kind: PortraitKind): Promise<void> {
private async generate(kind: PortraitKind, promptExtra?: string): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
@@ -181,13 +200,19 @@ export class PersonCardHost {
this.refreshPainted();
try {
const result = await generatePortrait(schoolId, personId, kind);
const result = await generatePortrait(schoolId, personId, kind, promptExtra);
const card = await fetchPerson(schoolId, personId, getLocale());
if (kind === 'custom') {
this.customPortraitRevision = Date.now();
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
}
this.painted = {
...card,
hasAvatar: result.hasAvatar,
hasHalfBody: result.hasHalfBody,
hasCustom: result.hasCustom,
hasFullBody: result.hasFullBody,
customPortraitPrompt: result.customPortraitPrompt,
};
} catch (error) {
this.portraitError =