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.
This commit is contained in:
+2
-2
@@ -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
|
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.
|
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
|
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
|
||||||
trying POST first.
|
so the client can disable generate buttons and show reachability without trying POST first.
|
||||||
|
|
||||||
### `GET /api/schools/{id}/staffing`
|
### `GET /api/schools/{id}/staffing`
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ const ru = {
|
|||||||
peoplePortraitGenerating: 'Генерация…',
|
peoplePortraitGenerating: 'Генерация…',
|
||||||
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
||||||
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
||||||
|
peoplePortraitSwarmDisabled: 'SwarmUI: не настроен',
|
||||||
|
peoplePortraitSwarmChecking: 'SwarmUI: проверка…',
|
||||||
|
peoplePortraitSwarmConnected: 'SwarmUI: подключён',
|
||||||
|
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
|
||||||
|
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
|
||||||
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
|
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
|
||||||
|
|
||||||
modeOverview: 'Обзор',
|
modeOverview: 'Обзор',
|
||||||
@@ -381,6 +386,11 @@ const en: Messages = {
|
|||||||
peoplePortraitGenerating: 'Generating…',
|
peoplePortraitGenerating: 'Generating…',
|
||||||
peoplePortraitMissing: 'Not generated yet.',
|
peoplePortraitMissing: 'Not generated yet.',
|
||||||
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
|
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.',
|
peoplePortraitFailed: 'Could not generate the portrait.',
|
||||||
|
|
||||||
modeOverview: 'Overview',
|
modeOverview: 'Overview',
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ export interface GameStatus {
|
|||||||
readonly maxSchools: number;
|
readonly maxSchools: number;
|
||||||
readonly connections: number;
|
readonly connections: number;
|
||||||
readonly swarmUiConfigured: boolean;
|
readonly swarmUiConfigured: boolean;
|
||||||
|
/** null when SwarmUI is not configured on the server. */
|
||||||
|
readonly swarmUiConnected: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameStatus(): Promise<GameStatus> {
|
export async function fetchGameStatus(): Promise<GameStatus> {
|
||||||
|
|||||||
@@ -643,6 +643,24 @@ body {
|
|||||||
margin-bottom: 16px;
|
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 {
|
.people__card-name {
|
||||||
margin: 0 0 2px;
|
margin: 0 0 2px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|||||||
@@ -70,18 +70,13 @@ export class ApplicantsDialog {
|
|||||||
private busy = false;
|
private busy = false;
|
||||||
private cardTab: PersonCardTab = 'overview';
|
private cardTab: PersonCardTab = 'overview';
|
||||||
private swarmConfigured = false;
|
private swarmConfigured = false;
|
||||||
|
private swarmConnected: boolean | null = null;
|
||||||
private painted: PersonCard | null = null;
|
private painted: PersonCard | null = null;
|
||||||
|
|
||||||
constructor(private readonly options: ApplicantsDialogOptions) {
|
constructor(private readonly options: ApplicantsDialogOptions) {
|
||||||
this.staffing = options.staffing;
|
this.staffing = options.staffing;
|
||||||
this.selectedId = options.selectedId ?? null;
|
this.selectedId = options.selectedId ?? null;
|
||||||
void fetchGameStatus()
|
void this.refreshSwarmStatus();
|
||||||
.then((status) => {
|
|
||||||
this.swarmConfigured = status.swarmUiConfigured;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.swarmConfigured = false;
|
|
||||||
});
|
|
||||||
this.error.hidden = true;
|
this.error.hidden = true;
|
||||||
this.ageMinInput.min = '0';
|
this.ageMinInput.min = '0';
|
||||||
this.ageMaxInput.min = '0';
|
this.ageMaxInput.min = '0';
|
||||||
@@ -325,15 +320,33 @@ export class ApplicantsDialog {
|
|||||||
tab: this.cardTab,
|
tab: this.cardTab,
|
||||||
onTabChange: (tab) => {
|
onTabChange: (tab) => {
|
||||||
this.cardTab = tab;
|
this.cardTab = tab;
|
||||||
if (this.painted !== null) {
|
if (tab === 'portrait') {
|
||||||
|
void this.refreshSwarmStatus();
|
||||||
|
} else if (this.painted !== null) {
|
||||||
this.paintCard(this.painted);
|
this.paintCard(this.painted);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swarmConfigured: this.swarmConfigured,
|
swarmConfigured: this.swarmConfigured,
|
||||||
|
swarmConnected: this.swarmConnected,
|
||||||
});
|
});
|
||||||
this.appendHireIfNeeded(this.card);
|
this.appendHireIfNeeded(this.card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSwarmStatus(): Promise<void> {
|
||||||
|
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 {
|
private appendHireIfNeeded(parent: HTMLElement = this.card): void {
|
||||||
const personId = this.selectedId ?? this.painted?.id;
|
const personId = this.selectedId ?? this.painted?.id;
|
||||||
const applicant =
|
const applicant =
|
||||||
|
|||||||
@@ -160,4 +160,17 @@ describe('renderPersonCard', () => {
|
|||||||
portraitTab?.click();
|
portraitTab?.click();
|
||||||
expect(onTabChange).toHaveBeenCalledWith('portrait');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface PersonCardHandlers {
|
|||||||
readonly portraitBusy?: 'avatar' | 'full' | null;
|
readonly portraitBusy?: 'avatar' | 'full' | null;
|
||||||
readonly portraitError?: string | null;
|
readonly portraitError?: string | null;
|
||||||
readonly swarmConfigured?: boolean;
|
readonly swarmConfigured?: boolean;
|
||||||
|
/** null while checking or when SwarmUI is not configured. */
|
||||||
|
readonly swarmConnected?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonCardMount {
|
export interface PersonCardMount {
|
||||||
@@ -122,6 +124,8 @@ function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCa
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
|
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(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
|
||||||
parent.append(portraitPreview(card, handlers, 'avatar'));
|
parent.append(portraitPreview(card, handlers, 'avatar'));
|
||||||
parent.append(
|
parent.append(
|
||||||
@@ -129,7 +133,7 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
|
|||||||
class: 'button button--small',
|
class: 'button button--small',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
|
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'),
|
onClick: () => handlers.onGeneratePortrait?.('avatar'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -141,13 +145,15 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
|
|||||||
class: 'button button--small',
|
class: 'button button--small',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
|
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'),
|
onClick: () => handlers.onGeneratePortrait?.('full'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (handlers.swarmConfigured === false) {
|
if (handlers.swarmConfigured === false) {
|
||||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
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) {
|
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 {
|
function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement {
|
||||||
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
|
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
|
||||||
if (hasImage && handlers.schoolId !== null) {
|
if (hasImage && handlers.schoolId !== null) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class PersonCardHost {
|
|||||||
private portraitBusy: 'avatar' | 'full' | null = null;
|
private portraitBusy: 'avatar' | 'full' | null = null;
|
||||||
private portraitError: string | null = null;
|
private portraitError: string | null = null;
|
||||||
private swarmConfigured = false;
|
private swarmConfigured = false;
|
||||||
|
private swarmConnected: boolean | null = null;
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
private painted: PersonCard | null = null;
|
private painted: PersonCard | null = null;
|
||||||
private mount: PersonCardMount | null = null;
|
private mount: PersonCardMount | null = null;
|
||||||
@@ -25,13 +26,20 @@ export class PersonCardHost {
|
|||||||
|
|
||||||
attach(schoolId: number): void {
|
attach(schoolId: number): void {
|
||||||
this.schoolId = schoolId;
|
this.schoolId = schoolId;
|
||||||
void fetchGameStatus()
|
void this.refreshSwarmStatus();
|
||||||
.then((status) => {
|
}
|
||||||
this.swarmConfigured = status.swarmUiConfigured;
|
|
||||||
})
|
private async refreshSwarmStatus(): Promise<void> {
|
||||||
.catch(() => {
|
try {
|
||||||
this.swarmConfigured = false;
|
const status = await fetchGameStatus();
|
||||||
});
|
this.swarmConfigured = status.swarmUiConfigured;
|
||||||
|
this.swarmConnected = status.swarmUiConnected;
|
||||||
|
} catch {
|
||||||
|
this.swarmConfigured = false;
|
||||||
|
this.swarmConnected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshPainted();
|
||||||
}
|
}
|
||||||
|
|
||||||
detach(): void {
|
detach(): void {
|
||||||
@@ -43,6 +51,7 @@ export class PersonCardHost {
|
|||||||
this.tab = 'overview';
|
this.tab = 'overview';
|
||||||
this.portraitBusy = null;
|
this.portraitBusy = null;
|
||||||
this.portraitError = null;
|
this.portraitError = null;
|
||||||
|
this.swarmConnected = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
paint(
|
paint(
|
||||||
@@ -87,12 +96,17 @@ export class PersonCardHost {
|
|||||||
tab: this.tab,
|
tab: this.tab,
|
||||||
onTabChange: (tab) => {
|
onTabChange: (tab) => {
|
||||||
this.tab = tab;
|
this.tab = tab;
|
||||||
this.refreshPainted();
|
if (tab === 'portrait') {
|
||||||
|
void this.refreshSwarmStatus();
|
||||||
|
} else {
|
||||||
|
this.refreshPainted();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onGeneratePortrait: (kind) => void this.generate(kind),
|
onGeneratePortrait: (kind) => void this.generate(kind),
|
||||||
portraitBusy: this.portraitBusy,
|
portraitBusy: this.portraitBusy,
|
||||||
portraitError: this.portraitError,
|
portraitError: this.portraitError,
|
||||||
swarmConfigured: this.swarmConfigured,
|
swarmConfigured: this.swarmConfigured,
|
||||||
|
swarmConnected: this.swarmConnected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ internal sealed class SwarmUiClient
|
|||||||
|
|
||||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl);
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl);
|
||||||
|
|
||||||
|
/// <summary>Opens a session to verify SwarmUI responds; does not generate an image.</summary>
|
||||||
|
public async Task<bool> 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<byte[]> GenerateAsync(
|
public async Task<byte[]> GenerateAsync(
|
||||||
string prompt,
|
string prompt,
|
||||||
string negativePrompt,
|
string negativePrompt,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>Caches a lightweight SwarmUI reachability probe so /api/status stays cheap.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary><c>null</c> when SwarmUI is not configured.</summary>
|
||||||
|
public async Task<bool?> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<GameSocketHandler>();
|
|||||||
builder.Services.AddSingleton<GameLoopService>();
|
builder.Services.AddSingleton<GameLoopService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||||
builder.Services.AddSwarmUi(builder.Configuration);
|
builder.Services.AddSwarmUi(builder.Configuration);
|
||||||
|
builder.Services.AddSingleton<SwarmUiHealthService>();
|
||||||
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
|
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
|
||||||
builder.Services.AddSingleton<PortraitService>();
|
builder.Services.AddSingleton<PortraitService>();
|
||||||
|
|
||||||
@@ -60,16 +61,19 @@ app.MapSchoolEndpoints();
|
|||||||
app.MapTimetableEndpoints();
|
app.MapTimetableEndpoints();
|
||||||
app.MapModEndpoints();
|
app.MapModEndpoints();
|
||||||
|
|
||||||
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm) =>
|
app.MapGet("/api/status", async (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm, SwarmUiHealthService swarmHealth, CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
var state = loop.SchoolsState;
|
var state = loop.SchoolsState;
|
||||||
|
var configured = !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl);
|
||||||
|
var connected = configured ? await swarmHealth.GetConnectedAsync(cancellationToken) : null;
|
||||||
return new GameStatusResponse(
|
return new GameStatusResponse(
|
||||||
loop.CurrentTick,
|
loop.CurrentTick,
|
||||||
loop.Options.TickRate,
|
loop.Options.TickRate,
|
||||||
state.Schools.Count,
|
state.Schools.Count,
|
||||||
state.MaxSchools,
|
state.MaxSchools,
|
||||||
clients.Count,
|
clients.Count,
|
||||||
!string.IsNullOrWhiteSpace(swarm.Value.BaseUrl));
|
configured,
|
||||||
|
connected);
|
||||||
})
|
})
|
||||||
.WithName("GetGameStatus");
|
.WithName("GetGameStatus");
|
||||||
|
|
||||||
@@ -119,7 +123,8 @@ internal sealed record GameStatusResponse(
|
|||||||
int Schools,
|
int Schools,
|
||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
int Connections,
|
int Connections,
|
||||||
bool SwarmUiConfigured);
|
bool SwarmUiConfigured,
|
||||||
|
bool? SwarmUiConnected);
|
||||||
|
|
||||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
|
"model": "dreamshaperXL_lightningDPMSDE.safetensors",
|
||||||
"steps": 8,
|
"steps": 4,
|
||||||
"cfgScale": 1,
|
"cfgScale": 2,
|
||||||
"clipSkip": 1,
|
"clipSkip": 2,
|
||||||
"sampler": "euler",
|
"sampler": "DPM++ SDE Karras",
|
||||||
"seed": -1,
|
"seed": 3346112079,
|
||||||
"positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.",
|
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||||
"negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo",
|
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"width": 512,
|
"width": 512,
|
||||||
"height": 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": {
|
"fullBody": {
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"height": 1024,
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,44 @@ public class SwarmUiClientTests
|
|||||||
Assert.Contains("/API/GenerateText2Image", handler.Requests[1]);
|
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<SwarmUiClient>.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<SwarmUiClient>.Instance);
|
||||||
|
|
||||||
|
var ok = await client.ProbeAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeHandler : HttpMessageHandler
|
private sealed class FakeHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
public List<string> Requests { get; } = [];
|
public List<string> Requests { get; } = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user