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.
ci / server (push) Failing after 3m44s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 05:32:40 +03:00
parent 3fa6ce95df
commit 6eb16d0edf
5 changed files with 99 additions and 18 deletions
@@ -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');
+5 -5
View File
@@ -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) {
+24 -12
View File
@@ -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 = {
+8 -1
View File
@@ -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;
}
}
@@ -2,6 +2,9 @@ using System.Net;
using System.Text;
using System.Text.Json;
using HSchool.Server.Game;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -100,6 +103,38 @@ public class SwarmUiClientTests
Assert.False(document.RootElement.TryGetProperty("clipskip", out _));
}
[Fact]
public void Registration_StripsStandardResilienceHandler()
{
var services = new ServiceCollection();
services.AddLogging();
services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SwarmUi:BaseUrl"] = "http://127.0.0.1:7801",
["SwarmUi:TimeoutSeconds"] = "180",
})
.Build();
services.AddSwarmUi(configuration);
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
using var handler = factory.CreateHandler(nameof(SwarmUiClient));
Assert.DoesNotContain(
HandlerTypeNames(handler),
name => name.Contains("Resilience", StringComparison.Ordinal));
}
private static IEnumerable<string> HandlerTypeNames(HttpMessageHandler handler)
{
for (HttpMessageHandler? current = handler; current is not null; current = (current as DelegatingHandler)?.InnerHandler)
{
yield return current.GetType().FullName ?? current.GetType().Name;
}
}
private sealed class CapturingHandler : HttpMessageHandler
{
public string? GenerateBody { get; private set; }