From 5991dcd5c42536c90ac6995ee67464b9f365c587 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 04:21:35 +0300 Subject: [PATCH] Enhance SwarmUI integration by adding swarm connection status to the API and updating client-side components to reflect this status. Improved localization strings for various SwarmUI states and refactored ApplicantsDialog and PersonCard components to utilize the new connection information, ensuring a more responsive user experience. --- docs/protocol.md | 4 +- src/HSchool.Client/src/i18n/strings.ts | 10 +++++ src/HSchool.Client/src/net/api.ts | 2 + src/HSchool.Client/src/style.css | 18 ++++++++ src/HSchool.Client/src/ui/applicantsDialog.ts | 29 +++++++++---- src/HSchool.Client/src/ui/personCard.test.ts | 13 ++++++ src/HSchool.Client/src/ui/personCard.ts | 34 ++++++++++++++- src/HSchool.Client/src/ui/personCardHost.ts | 30 +++++++++---- src/HSchool.Server/Game/SwarmUiClient.cs | 27 ++++++++++++ .../Game/SwarmUiHealthService.cs | 43 +++++++++++++++++++ src/HSchool.Server/Program.cs | 11 +++-- src/HSchool.Server/swarmui.json | 20 ++++----- .../SwarmUiClientTests.cs | 38 ++++++++++++++++ 13 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 src/HSchool.Server/Game/SwarmUiHealthService.cs diff --git a/docs/protocol.md b/docs/protocol.md index e75e0e4..27beb78 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -333,8 +333,8 @@ at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swa Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files land under `saves/{id}.portraits/` and survive until the school is deleted. -`GET /api/status` includes `swarmUiConfigured` so the client can disable generate buttons without -trying POST first. +`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured) +so the client can disable generate buttons and show reachability without trying POST first. ### `GET /api/schools/{id}/staffing` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 2d5c8a6..10508dc 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -146,6 +146,11 @@ const ru = { peoplePortraitGenerating: 'Генерация…', peoplePortraitMissing: 'Ещё не сгенерировано.', peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.', + peoplePortraitSwarmDisabled: 'SwarmUI: не настроен', + peoplePortraitSwarmChecking: 'SwarmUI: проверка…', + peoplePortraitSwarmConnected: 'SwarmUI: подключён', + peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи', + peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.', peoplePortraitFailed: 'Не удалось сгенерировать портрет.', modeOverview: 'Обзор', @@ -381,6 +386,11 @@ const en: Messages = { peoplePortraitGenerating: 'Generating…', peoplePortraitMissing: 'Not generated yet.', peoplePortraitUnavailable: 'SwarmUI is not configured on the server.', + peoplePortraitSwarmDisabled: 'SwarmUI: not configured', + peoplePortraitSwarmChecking: 'SwarmUI: checking…', + peoplePortraitSwarmConnected: 'SwarmUI: connected', + peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable', + peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.', peoplePortraitFailed: 'Could not generate the portrait.', modeOverview: 'Overview', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index d4e87e0..b13ca52 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -346,6 +346,8 @@ export interface GameStatus { readonly maxSchools: number; readonly connections: number; readonly swarmUiConfigured: boolean; + /** null when SwarmUI is not configured on the server. */ + readonly swarmUiConnected: boolean | null; } export async function fetchGameStatus(): Promise { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 49c6000..c442972 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -643,6 +643,24 @@ body { margin-bottom: 16px; } +.people__swarm-status { + margin: 0 0 12px; + font-size: 13px; +} + +.people__swarm-status--ok { + color: var(--ok, #2e7d32); +} + +.people__swarm-status--bad { + color: var(--danger, #c62828); +} + +.people__swarm-status--pending, +.people__swarm-status--off { + color: var(--text-muted, #666); +} + .people__card-name { margin: 0 0 2px; font-size: 15px; diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts index a966763..c79cfb3 100644 --- a/src/HSchool.Client/src/ui/applicantsDialog.ts +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -70,18 +70,13 @@ export class ApplicantsDialog { private busy = false; private cardTab: PersonCardTab = 'overview'; private swarmConfigured = false; + private swarmConnected: boolean | null = null; private painted: PersonCard | null = null; constructor(private readonly options: ApplicantsDialogOptions) { this.staffing = options.staffing; this.selectedId = options.selectedId ?? null; - void fetchGameStatus() - .then((status) => { - this.swarmConfigured = status.swarmUiConfigured; - }) - .catch(() => { - this.swarmConfigured = false; - }); + void this.refreshSwarmStatus(); this.error.hidden = true; this.ageMinInput.min = '0'; this.ageMaxInput.min = '0'; @@ -325,15 +320,33 @@ export class ApplicantsDialog { tab: this.cardTab, onTabChange: (tab) => { this.cardTab = tab; - if (this.painted !== null) { + if (tab === 'portrait') { + void this.refreshSwarmStatus(); + } else if (this.painted !== null) { this.paintCard(this.painted); } }, swarmConfigured: this.swarmConfigured, + swarmConnected: this.swarmConnected, }); this.appendHireIfNeeded(this.card); } + private async refreshSwarmStatus(): Promise { + try { + const status = await fetchGameStatus(); + this.swarmConfigured = status.swarmUiConfigured; + this.swarmConnected = status.swarmUiConnected; + } catch { + this.swarmConfigured = false; + this.swarmConnected = null; + } + + if (this.painted !== null) { + this.paintCard(this.painted); + } + } + private appendHireIfNeeded(parent: HTMLElement = this.card): void { const personId = this.selectedId ?? this.painted?.id; const applicant = diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index ecb6cc8..b910da1 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -160,4 +160,17 @@ describe('renderPersonCard', () => { portraitTab?.click(); expect(onTabChange).toHaveBeenCalledWith('portrait'); }); + + it('shows Swarm connection status on the portrait tab', () => { + setLocale('en'); + const root = document.createElement('div'); + renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: true })); + + expect(root.querySelector('.people__swarm-status--ok')?.textContent).toContain('connected'); + + root.replaceChildren(); + renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: false })); + + expect(root.querySelector('.people__swarm-status--bad')?.textContent).toContain('unreachable'); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index e997d12..ec39ea3 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -21,6 +21,8 @@ export interface PersonCardHandlers { readonly portraitBusy?: 'avatar' | 'full' | null; readonly portraitError?: string | null; readonly swarmConfigured?: boolean; + /** null while checking or when SwarmUI is not configured. */ + readonly swarmConnected?: boolean | null; } export interface PersonCardMount { @@ -122,6 +124,8 @@ function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCa } function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { + parent.append(swarmStatusLine(handlers)); + parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') })); parent.append(portraitPreview(card, handlers, 'avatar')); parent.append( @@ -129,7 +133,7 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa class: 'button button--small', type: 'button', text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), - disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false, + disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), onClick: () => handlers.onGeneratePortrait?.('avatar'), }), ); @@ -141,13 +145,15 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa class: 'button button--small', type: 'button', text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), - disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false, + disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), onClick: () => handlers.onGeneratePortrait?.('full'), }), ); if (handlers.swarmConfigured === false) { parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); + } else if (handlers.swarmConfigured === true && handlers.swarmConnected === false) { + parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') })); } if ((handlers.portraitError?.length ?? 0) > 0) { @@ -155,6 +161,30 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa } } +function portraitGenerateEnabled(handlers: PersonCardHandlers): boolean { + if (handlers.swarmConfigured !== true) { + return false; + } + + return handlers.swarmConnected === true; +} + +function swarmStatusLine(handlers: PersonCardHandlers): HTMLElement { + if (handlers.swarmConfigured === false) { + return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') }); + } + + if (handlers.swarmConnected === true) { + return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') }); + } + + if (handlers.swarmConnected === false) { + return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') }); + } + + return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') }); +} + function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement { const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; if (hasImage && handlers.schoolId !== null) { diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 9abddbb..9bf0e0d 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -15,6 +15,7 @@ export class PersonCardHost { private portraitBusy: 'avatar' | 'full' | null = null; private portraitError: string | null = null; private swarmConfigured = false; + private swarmConnected: boolean | null = null; private schoolId: number | null = null; private painted: PersonCard | null = null; private mount: PersonCardMount | null = null; @@ -25,13 +26,20 @@ export class PersonCardHost { attach(schoolId: number): void { this.schoolId = schoolId; - void fetchGameStatus() - .then((status) => { - this.swarmConfigured = status.swarmUiConfigured; - }) - .catch(() => { - this.swarmConfigured = false; - }); + void this.refreshSwarmStatus(); + } + + private async refreshSwarmStatus(): Promise { + try { + const status = await fetchGameStatus(); + this.swarmConfigured = status.swarmUiConfigured; + this.swarmConnected = status.swarmUiConnected; + } catch { + this.swarmConfigured = false; + this.swarmConnected = null; + } + + this.refreshPainted(); } detach(): void { @@ -43,6 +51,7 @@ export class PersonCardHost { this.tab = 'overview'; this.portraitBusy = null; this.portraitError = null; + this.swarmConnected = null; } paint( @@ -87,12 +96,17 @@ export class PersonCardHost { tab: this.tab, onTabChange: (tab) => { this.tab = tab; - this.refreshPainted(); + if (tab === 'portrait') { + void this.refreshSwarmStatus(); + } else { + this.refreshPainted(); + } }, onGeneratePortrait: (kind) => void this.generate(kind), portraitBusy: this.portraitBusy, portraitError: this.portraitError, swarmConfigured: this.swarmConfigured, + swarmConnected: this.swarmConnected, }; } diff --git a/src/HSchool.Server/Game/SwarmUiClient.cs b/src/HSchool.Server/Game/SwarmUiClient.cs index 1c7c9b9..50c05f3 100644 --- a/src/HSchool.Server/Game/SwarmUiClient.cs +++ b/src/HSchool.Server/Game/SwarmUiClient.cs @@ -28,6 +28,33 @@ internal sealed class SwarmUiClient public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl); + /// Opens a session to verify SwarmUI responds; does not generate an image. + public async Task ProbeAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + if (!IsConfigured) + { + return false; + } + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + try + { + await RefreshSessionAsync(timeoutSource.Token); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "SwarmUI probe failed."); + return false; + } + } + public async Task GenerateAsync( string prompt, string negativePrompt, diff --git a/src/HSchool.Server/Game/SwarmUiHealthService.cs b/src/HSchool.Server/Game/SwarmUiHealthService.cs new file mode 100644 index 0000000..b093437 --- /dev/null +++ b/src/HSchool.Server/Game/SwarmUiHealthService.cs @@ -0,0 +1,43 @@ +namespace HSchool.Server.Game; + +/// Caches a lightweight SwarmUI reachability probe so /api/status stays cheap. +internal sealed class SwarmUiHealthService(SwarmUiClient client) +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(5); + + private readonly SemaphoreSlim _gate = new(1, 1); + private bool? _lastConnected; + private DateTime _lastCheckUtc = DateTime.MinValue; + + /// null when SwarmUI is not configured. + public async Task GetConnectedAsync(CancellationToken cancellationToken) + { + if (!client.IsConfigured) + { + return null; + } + + if (DateTime.UtcNow - _lastCheckUtc < CacheTtl && _lastConnected.HasValue) + { + return _lastConnected; + } + + await _gate.WaitAsync(cancellationToken); + try + { + if (DateTime.UtcNow - _lastCheckUtc < CacheTtl && _lastConnected.HasValue) + { + return _lastConnected; + } + + _lastConnected = await client.ProbeAsync(ProbeTimeout, cancellationToken); + _lastCheckUtc = DateTime.UtcNow; + return _lastConnected; + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 0f4b74d..6d5ab47 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -37,6 +37,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSwarmUi(builder.Configuration); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService(), sp.GetRequiredService().CreateLogger("SwarmUiSettings"))); builder.Services.AddSingleton(); @@ -60,16 +61,19 @@ app.MapSchoolEndpoints(); app.MapTimetableEndpoints(); app.MapModEndpoints(); -app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions swarm) => +app.MapGet("/api/status", async (GameLoopService loop, ClientRegistry clients, IOptions swarm, SwarmUiHealthService swarmHealth, CancellationToken cancellationToken) => { var state = loop.SchoolsState; + var configured = !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl); + var connected = configured ? await swarmHealth.GetConnectedAsync(cancellationToken) : null; return new GameStatusResponse( loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count, - !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl)); + configured, + connected); }) .WithName("GetGameStatus"); @@ -119,7 +123,8 @@ internal sealed record GameStatusResponse( int Schools, int MaxSchools, int Connections, - bool SwarmUiConfigured); + bool SwarmUiConfigured, + bool? SwarmUiConnected); /// Exposed so WebApplicationFactory-style tests can reference the entry point. public partial class Program; diff --git a/src/HSchool.Server/swarmui.json b/src/HSchool.Server/swarmui.json index 4926c0f..5a610db 100644 --- a/src/HSchool.Server/swarmui.json +++ b/src/HSchool.Server/swarmui.json @@ -1,20 +1,20 @@ { - "model": "pornmasterFlux2Klein_v4TurboFp8.safetensors", - "steps": 8, - "cfgScale": 1, - "clipSkip": 1, - "sampler": "euler", - "seed": -1, - "positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.", - "negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo", + "model": "dreamshaperXL_lightningDPMSDE.safetensors", + "steps": 4, + "cfgScale": 2, + "clipSkip": 2, + "sampler": "DPM++ SDE Karras", + "seed": 3346112079, + "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus", + "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo", "avatar": { "width": 512, "height": 512, - "positive": "Head and shoulders portrait, facing the camera, upper body visible." + "positive": "close up, head and shoulders portrait, facing the camera, upper body visible." }, "fullBody": { "width": 768, "height": 1024, - "positive": "Full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." + "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." } } diff --git a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs index 5145a1a..a39c4fc 100644 --- a/tests/HSchool.Server.Tests/SwarmUiClientTests.cs +++ b/tests/HSchool.Server.Tests/SwarmUiClientTests.cs @@ -34,6 +34,44 @@ public class SwarmUiClientTests Assert.Contains("/API/GenerateText2Image", handler.Requests[1]); } + [Fact] + public async Task ProbeAsync_ReturnsTrueWhenSessionOpens() + { + var handler = new FakeHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") }; + var client = new SwarmUiClient( + http, + Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), + NullLogger.Instance); + + var ok = await client.ProbeAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + + Assert.True(ok); + Assert.Single(handler.Requests); + Assert.Contains("/API/GetNewSession", handler.Requests[0]); + } + + [Fact] + public async Task ProbeAsync_ReturnsFalseWhenUnreachable() + { + var handler = new FailingHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") }; + var client = new SwarmUiClient( + http, + Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }), + NullLogger.Instance); + + var ok = await client.ProbeAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + + Assert.False(ok); + } + + private sealed class FailingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + } + private sealed class FakeHandler : HttpMessageHandler { public List Requests { get; } = [];