Implement portrait prompt API and UI enhancements. Added a new endpoint to fetch portrait prompts, updated the client to handle prompt display, and improved localization for prompt-related text. Enhanced styling for prompt elements in the UI.
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 15s

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