Enhance person card functionality: add custom prompt textarea persistence during input, streamline portrait prompt API by removing unused parameters, and ensure proper button state management based on input. Update tests to verify new behavior.
This commit is contained in:
@@ -165,6 +165,33 @@ describe('renderPersonCard', () => {
|
||||
expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar');
|
||||
});
|
||||
|
||||
it('keeps the custom prompt textarea while typing', () => {
|
||||
let draft = '';
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(
|
||||
root,
|
||||
card(),
|
||||
() => {},
|
||||
options({
|
||||
tab: 'portrait',
|
||||
customPrompt: '',
|
||||
onCustomPromptChange: (value) => {
|
||||
draft = value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const textarea = root.querySelector('.people__portrait-prompt-input');
|
||||
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
|
||||
const field = textarea as HTMLTextAreaElement;
|
||||
field.value = 'school hallway';
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
expect(draft).toBe('school hallway');
|
||||
expect(field.value).toBe('school hallway');
|
||||
expect(root.querySelector('.people__portrait-prompt-input')).toBe(field);
|
||||
});
|
||||
|
||||
it('does not mount apparel on the portrait tab', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface RenderPersonCardOptions {
|
||||
readonly portraitPrompts?: Partial<Record<PortraitKind, PortraitPrompt>>;
|
||||
readonly portraitPromptLoading?: PortraitKind | null;
|
||||
readonly portraitPromptError?: string | null;
|
||||
readonly onShowPortraitPrompt?: (kind: PortraitKind, promptExtra?: string) => void;
|
||||
readonly onShowPortraitPrompt?: (kind: PortraitKind) => void;
|
||||
readonly swarmConfigured?: boolean;
|
||||
/** null while checking or when SwarmUI is not configured. */
|
||||
readonly swarmConnected?: boolean | null;
|
||||
@@ -394,6 +394,7 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
dataset: { portraitAction: 'generate-custom' },
|
||||
text:
|
||||
options.portraitBusy === 'custom'
|
||||
? t('peoplePortraitGenerating')
|
||||
@@ -404,11 +405,11 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
||||
(options.portraitBusy ?? null) !== null ||
|
||||
!portraitGenerateEnabled(options) ||
|
||||
customPrompt.length === 0,
|
||||
onClick: () => options.onGeneratePortrait?.('custom', customPrompt),
|
||||
onClick: () => options.onGeneratePortrait?.('custom'),
|
||||
}),
|
||||
);
|
||||
if (customPrompt.length > 0 || card.hasCustom) {
|
||||
parent.append(portraitPromptView('custom', options, customPrompt.length > 0 ? customPrompt : undefined));
|
||||
parent.append(portraitPromptView('custom', options));
|
||||
}
|
||||
|
||||
if (options.swarmConfigured === false) {
|
||||
@@ -454,7 +455,6 @@ const PORTRAIT_VARIANTS: readonly {
|
||||
function portraitPromptView(
|
||||
kind: PortraitKind,
|
||||
options: RenderPersonCardOptions,
|
||||
promptExtra?: string,
|
||||
): HTMLElement {
|
||||
const open = options.portraitPromptOpen === kind;
|
||||
const loading = options.portraitPromptLoading === kind;
|
||||
@@ -464,7 +464,7 @@ function portraitPromptView(
|
||||
type: 'button',
|
||||
text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'),
|
||||
disabled: (options.portraitPromptLoading ?? null) !== null && !loading,
|
||||
onClick: () => options.onShowPortraitPrompt?.(kind, promptExtra),
|
||||
onClick: () => options.onShowPortraitPrompt?.(kind),
|
||||
});
|
||||
|
||||
if (!open && !loading) {
|
||||
|
||||
@@ -162,20 +162,14 @@ export class PersonCardHost {
|
||||
portraitError: this.portraitError,
|
||||
customPrompt: this.customPromptDraft,
|
||||
onCustomPromptChange: (value) => {
|
||||
this.customPromptDraft = value;
|
||||
if (this.portraitPromptOpen === 'custom') {
|
||||
this.portraitPromptOpen = null;
|
||||
delete this.portraitPrompts.custom;
|
||||
}
|
||||
|
||||
this.refreshPainted();
|
||||
this.syncCustomPortraitInput(value);
|
||||
},
|
||||
customPortraitRevision: this.customPortraitRevision,
|
||||
portraitPromptOpen: this.portraitPromptOpen,
|
||||
portraitPrompts: this.portraitPrompts,
|
||||
portraitPromptLoading: this.portraitPromptLoading,
|
||||
portraitPromptError: this.portraitPromptError,
|
||||
onShowPortraitPrompt: (kind, promptExtra) => void this.togglePortraitPrompt(kind, promptExtra),
|
||||
onShowPortraitPrompt: (kind) => void this.togglePortraitPrompt(kind),
|
||||
swarmConfigured: this.swarmConfigured,
|
||||
swarmConnected: this.swarmConnected,
|
||||
});
|
||||
@@ -211,7 +205,22 @@ export class PersonCardHost {
|
||||
}
|
||||
}
|
||||
|
||||
private async togglePortraitPrompt(kind: PortraitKind, promptExtra?: string): Promise<void> {
|
||||
private syncCustomPortraitInput(draft: string): void {
|
||||
this.customPromptDraft = draft;
|
||||
const button = this.container?.querySelector('[data-portrait-action="generate-custom"]');
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = draft.trim();
|
||||
button.disabled =
|
||||
this.portraitBusy !== null ||
|
||||
this.swarmConfigured !== true ||
|
||||
this.swarmConnected !== true ||
|
||||
trimmed.length === 0;
|
||||
}
|
||||
|
||||
private async togglePortraitPrompt(kind: PortraitKind): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const personId = this.painted?.id;
|
||||
if (schoolId === null || personId === undefined || this.portraitPromptLoading !== null) {
|
||||
@@ -229,8 +238,10 @@ export class PersonCardHost {
|
||||
this.portraitPromptError = null;
|
||||
this.refreshPainted();
|
||||
|
||||
const resolvedExtra = kind === 'custom' ? this.customPromptDraft.trim() || undefined : undefined;
|
||||
|
||||
try {
|
||||
const prompt = await fetchPortraitPrompt(schoolId, personId, kind, promptExtra);
|
||||
const prompt = await fetchPortraitPrompt(schoolId, personId, kind, resolvedExtra);
|
||||
this.portraitPrompts[kind] = prompt;
|
||||
this.portraitPromptOpen = kind;
|
||||
} catch {
|
||||
@@ -253,11 +264,12 @@ export class PersonCardHost {
|
||||
this.refreshPainted();
|
||||
|
||||
try {
|
||||
const result = await generatePortrait(schoolId, personId, kind, promptExtra);
|
||||
const extra = kind === 'custom' ? this.customPromptDraft.trim() : promptExtra;
|
||||
const result = await generatePortrait(schoolId, personId, kind, extra);
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
if (kind === 'custom') {
|
||||
this.customPortraitRevision = Date.now();
|
||||
this.customPromptDraft = result.customPortraitPrompt ?? promptExtra ?? '';
|
||||
this.customPromptDraft = result.customPortraitPrompt ?? extra ?? '';
|
||||
}
|
||||
|
||||
this.painted = {
|
||||
|
||||
@@ -193,7 +193,7 @@ internal static class SwarmUiClientRegistration
|
||||
.Validate(options => options.TimeoutSeconds is > 0 and <= 3600, "SwarmUi:TimeoutSeconds must be between 1 and 3600.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<SwarmUiClient>((sp, client) =>
|
||||
var http = services.AddHttpClient<SwarmUiClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SwarmUiOptions>>().Value;
|
||||
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
@@ -208,6 +208,13 @@ internal static class SwarmUiClientRegistration
|
||||
}
|
||||
});
|
||||
|
||||
// AddServiceDefaults puts a 10s AttemptTimeout on every HttpClient. Image generation
|
||||
// is a long POST and not safe to retry; HttpClient.Timeout (SwarmUi:TimeoutSeconds) is
|
||||
// the only deadline that should apply.
|
||||
#pragma warning disable EXTEXP0001
|
||||
http.RemoveAllResilienceHandlers();
|
||||
#pragma warning restore EXTEXP0001
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user