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:
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;
|
||||
|
||||
Reference in New Issue
Block a user