diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 920b7d0..6746154 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -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'); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index 21132ff..f1d57ed 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -41,7 +41,7 @@ export interface RenderPersonCardOptions { readonly portraitPrompts?: Partial>; 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) { diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 5bf4850..63f5f1b 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -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 { + 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 { 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 = { diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index 3ee3b89..bd5708e 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -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((sp, client) => + var http = services.AddHttpClient((sp, client) => { var options = sp.GetRequiredService>().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; } } diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index 578c961..d5f357f 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -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 + { + ["SwarmUi:BaseUrl"] = "http://127.0.0.1:7801", + ["SwarmUi:TimeoutSeconds"] = "180", + }) + .Build(); + services.AddSwarmUi(configuration); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + using var handler = factory.CreateHandler(nameof(SwarmUiClient)); + + Assert.DoesNotContain( + HandlerTypeNames(handler), + name => name.Contains("Resilience", StringComparison.Ordinal)); + } + + private static IEnumerable 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; }