Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions client/src/pages/ImageGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ export default function ImageGen() {
// apply to it. Keep the target state intact so switching back to another
// backend restores the user's previous target selection.
const remoteTargetActive = effectiveMode !== IMAGE_GEN_MODE.GROK && remoteTarget.isRemote;
// The status probe describes THIS machine's backend, and it can hang for a long
// time against an unconfigured external SD API URL. A federated target renders
// on the peer, so only a LOCAL dispatch waits on the probe.
const localBackendPending = statusLoading && !remoteTargetActive;
const isAsyncMode = isLocalMode || isCloudMode || remoteTargetActive;
// Only probe `agy models` while Agy is the active backend — it spawns a
// child process server-side, so an unselected backend must not pay for it.
Expand Down Expand Up @@ -983,6 +987,11 @@ export default function ImageGen() {
// fires onSubmit — gate here too so an edit-only model without a source image
// (or codex text-to-image with no prompt) hits the inline hint, not a 400 toast.
if (editImageMissing || cloudNeedsPrompt) return;
// Same reason, for the backend probe: the form stays typable while the status
// pill is still checking, so an implicit submit must not dispatch against a
// backend we haven't confirmed. A remote target runs on the peer, so the
// LOCAL probe result doesn't gate it — mirror the submit button exactly.
if (localBackendPending || (!remoteTargetActive && notConnected)) return;
// The button reading is as old as the last render and a capacity window
// expires on the clock, so an enabled button can already be pointing at a
// lapsed peer. Re-derive here and say so, rather than letting the server
Expand Down Expand Up @@ -1293,20 +1302,17 @@ export default function ImageGen() {
<UniverseStylePicker
value={selectedUniverse?.id || ''}
onChange={setSelectedUniverse}
disabled={statusLoading}
/>
<StylePresetPicker
value={stylePreset?.id || ''}
onChange={setStylePreset}
disabled={statusLoading}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormField label="Prompt" labelClassName="block text-xs font-medium text-gray-400 mb-1">
<AutoSizeTextarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
disabled={statusLoading}
className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]"
placeholder="Describe the image you want to generate..."
/>
Expand All @@ -1316,7 +1322,6 @@ export default function ImageGen() {
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
rows={3}
disabled={statusLoading}
className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent disabled:opacity-50 min-h-[80px]"
placeholder="What to avoid..."
/>
Expand All @@ -1330,14 +1335,12 @@ export default function ImageGen() {
negativePrompt={negativePrompt}
setNegativePrompt={setNegativePrompt}
renderConfig={{ stylePreset: stylePreset?.id, mode: effectiveMode }}
disabled={statusLoading}
/>
<PromptFromMedia
kindDefault="both"
applyKind="image"
setPrompt={setPrompt}
setNegativePrompt={setNegativePrompt}
disabled={statusLoading}
/>

{flux2Issue === 'venv' && (
Expand All @@ -1349,7 +1352,6 @@ export default function ImageGen() {
<button
type="button"
onClick={() => setFlux2InstallOpen(true)}
disabled={statusLoading}
className="self-start sm:self-auto whitespace-nowrap inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-port-accent text-white text-xs font-medium hover:bg-port-accent/80 disabled:opacity-50"
>
<Sparkles size={14} />
Expand All @@ -1376,7 +1378,6 @@ export default function ImageGen() {
<RemoteMediaTargetPicker
target={remoteTarget}
kind="image"
disabled={statusLoading}
localBlockedReason={remoteUnsupportedInputs}
/>
)}
Expand All @@ -1397,7 +1398,6 @@ export default function ImageGen() {
quantize={quantize} onQuantizeChange={setQuantize}
seed={seed} onSeedChange={setSeed}
showSeed
disabled={statusLoading}
// The peer advertises its own models and runs its own quantization;
// the local dropdowns would name neither. Resolution/steps/guidance/
// seed do cross the wire, so those stay.
Expand Down Expand Up @@ -1431,7 +1431,6 @@ export default function ImageGen() {
// raise a hint nor be appended a second time.
onAppendTrigger={(words) => setPrompt((p) => appendTriggerWords(p, words, styledPrompt))}
prompt={styledPrompt}
disabled={statusLoading}
/>
)}

Expand All @@ -1445,7 +1444,6 @@ export default function ImageGen() {
onBrowse={() => setGalleryPicker({ kind: 'init' })}
editOnly={isEditOnlyModel}
backend={effectiveMode}
disabled={statusLoading}
/>
)}

Expand All @@ -1460,7 +1458,6 @@ export default function ImageGen() {
onClear={handleClearReferenceImage}
onStrengthChange={handleReferenceStrengthChange}
onBrowse={(slot) => setGalleryPicker({ kind: 'reference', slot })}
disabled={statusLoading}
/>
)}

Expand All @@ -1477,13 +1474,18 @@ export default function ImageGen() {
<div className="flex items-center gap-2 pt-1 flex-wrap">
<button
type="submit"
// The probe decides WHICH backend can run, not what the user may
// type — so it gates submit and backend selection only. Every form
// control above stays live while the status pill is still checking.
disabled={remoteTargetActive
? remoteBlocked !== null
: (notConnected || editImageMissing || cloudNeedsPrompt)}
title={remoteBlocked || (editImageMissing ? 'This image-edit model needs a source image — upload one below first' : cloudNeedsPrompt ? cloudPromptHint : undefined)}
: (localBackendPending || notConnected || editImageMissing || cloudNeedsPrompt)}
title={localBackendPending
? 'Checking the image backend…'
: remoteBlocked || (editImageMissing ? 'This image-edit model needs a source image — upload one below first' : cloudNeedsPrompt ? cloudPromptHint : undefined)}
className="flex items-center gap-2 px-4 py-2 bg-port-accent hover:bg-port-accent/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg min-h-[40px]"
>
<Sparkles className="w-4 h-4" /> {generating ? 'Queue' : 'Generate'}
<Sparkles className="w-4 h-4" /> {localBackendPending ? 'Checking…' : generating ? 'Queue' : 'Generate'}
{isAsyncMode && batchCount > 1 && <span className="text-xs opacity-80">× {batchCount}</span>}
</button>
{editImageMissing && (
Expand Down
166 changes: 166 additions & 0 deletions client/src/pages/ImageGen.probeGating.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 };

// A peer opted in as an image provider with a live capacity window — the shape
// `GET /api/instances` returns.
const PEER = {
id: 'peer-example',
name: 'Example GPU',
status: 'online',
enabled: true,
mediaProvider: { enabled: true, imageModels: [{ engine: 'local', modelId: 'peer-flux' }] },
mediaProviderStatus: {
state: 'ready',
checkedAt: new Date().toISOString(),
freshUntil: new Date(Date.now() + 60_000).toISOString(),
snapshot: {
queue: { accepting: true, running: 0, queued: 0, totalActive: 0, maxQueuedJobs: 4 },
capabilities: [{
kind: 'image', engine: 'local', engineName: 'Local image', modelId: 'peer-flux',
modelName: 'FLUX.2 Klein', ready: true, unavailableReason: null,
runtimeReady: true, platformSupported: true, cudaRequired: false, cudaState: 'available',
}],
},
},
};

// The backend probe is held open on purpose: an unconfigured `external` SD API
// URL times out, and that window used to grey out the whole form.
const state = vi.hoisted(() => ({ resolveStatus: null, statusPromise: null, generateImage: vi.fn() }));

vi.mock('../services/api', () => ({
getInstances: vi.fn(async () => ({ peers: [] })),
getImageGenStatus: vi.fn(() => state.statusPromise),
generateImage: (...args) => state.generateImage(...args),
generateImageMultipart: vi.fn(async () => ({})),
listImageModels: vi.fn(async () => [MODEL]),
listLorasFull: vi.fn(async () => []),
listImageGallery: vi.fn(async () => []),
cancelImageGen: vi.fn(async () => ({})),
deleteImage: vi.fn(async () => ({})),
setImageHidden: vi.fn(async () => ({})),
cleanGalleryImage: vi.fn(async () => ({})),
getActiveImageJob: vi.fn(async () => ({ activeJob: null })),
getSettings: vi.fn(async () => ({ imageGen: { mode: 'local', local: { pythonPath: '/usr/bin/python3' } } })),
buildFormData: vi.fn(() => new FormData()),
listMediaJobs: vi.fn(async () => ({ jobs: [] })),
regenerateGalleryImage: vi.fn(async () => ({})),
getRegenAvailability: vi.fn(async () => ({ available: false })),
removeImageWatermark: vi.fn(async () => ({})),
getFlux2Status: vi.fn(async () => ({ installed: true, ready: true })),
}));

vi.mock('../hooks/useImageGenProgress', () => ({
useImageGenProgress: () => ({ progress: null, begin: vi.fn(), end: vi.fn(), resume: vi.fn() }),
}));
vi.mock('../hooks/useMediaJobSse', () => ({
useMediaJobSse: () => ({ attach: vi.fn(), eventSourceRef: { current: null } }),
}));
vi.mock('../hooks/useModelDownloadStatus', () => ({
useModelDownloadStatus: () => ({
getStatus: () => ({ cached: true }), start: vi.fn(), cancel: vi.fn(), repair: vi.fn(), refresh: vi.fn(),
downloading: false, repairing: false, progress: null, lastError: null, activeModelId: null, extra: {}, loading: false, statusError: null,
}),
}));
vi.mock('../hooks/useHfTokenStatus', () => ({ useHfTokenStatus: () => ({ present: true, refresh: vi.fn() }) }));
vi.mock('../hooks/useAgyModels', () => ({ useAgyModels: () => ({ models: [], error: null }) }));
vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
vi.mock('../hooks/useMediaAnnotations', () => ({
useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
}));
vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: vi.fn() }));
vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
vi.mock('../components/ui/Toast', () => ({
default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
}));
vi.mock('../components/media/PromptEnhancer', () => ({ default: () => null }));
vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null }));
vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
vi.mock('../components/Drawer', () => ({ default: () => null }));
vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
vi.mock('../components/imageGen/Flux2InstallModal', () => ({ default: () => null }));
vi.mock('../components/imageGen/GalleryImagePicker', () => ({ default: () => null }));
vi.mock('../components/imageGen/InitImagePicker', () => ({ default: () => null }));
vi.mock('../components/imageGen/ReferenceImagePicker', () => ({ default: () => null }));
vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));

const { default: ImageGen } = await import('./ImageGen.jsx');

const mount = async () => {
await act(async () => {
render(
<MemoryRouter initialEntries={['/media/image']}>
<ImageGen />
</MemoryRouter>,
);
});
};

describe('ImageGen backend-probe gating', () => {
beforeEach(() => {
state.generateImage.mockReset().mockResolvedValue({ jobId: 'job-1' });
state.statusPromise = new Promise((resolve) => { state.resolveStatus = resolve; });
});

// The probe decides which backend can RUN, not what the user may TYPE. While
// it is in flight the whole above-the-fold form must stay usable.
it('leaves the prompt fields editable while the status probe is still in flight', async () => {
await mount();

expect(await screen.findByLabelText('Prompt')).not.toBeDisabled();
expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled();
expect(screen.getByRole('button', { name: /Checking…/ })).toBeDisabled();
});

// A probe that comes back unusable must still not take the form hostage —
// only submit stays blocked, so the user can compose while they fix settings.
it('keeps the prompt editable and submit blocked when the probe reports not connected', async () => {
await mount();
await act(async () => {
state.resolveStatus({ connected: false, mode: 'local', reason: 'Not configured' });
await state.statusPromise;
});

await waitFor(() => expect(screen.getByRole('button', { name: /^Generate$/ })).toBeDisabled());
expect(screen.getByLabelText('Prompt')).not.toBeDisabled();
expect(screen.getByLabelText('Negative Prompt')).not.toBeDisabled();
});

// A live form has a live implicit submit: Enter inside a number input fires
// onSubmit even when the default button is disabled, so the handler carries
// the same probe gate the button does.
it('refuses an implicit submit fired while the probe is still in flight', async () => {
await mount();

const prompt = await screen.findByLabelText('Prompt');
fireEvent.change(prompt, { target: { value: 'a lighthouse at dusk' } });
await act(async () => { fireEvent.submit(prompt.closest('form')); });

expect(state.generateImage).not.toHaveBeenCalled();
});

// A federated render runs on the peer, so THIS machine's probe — hung against
// an unconfigured SD API URL — must not hold the submit hostage.
it('still submits to a ready peer while the local probe hangs', async () => {
const { getInstances } = await import('../services/api');
getInstances.mockResolvedValueOnce({ peers: [PEER] });
await mount();

fireEvent.change(await screen.findByRole('combobox', { name: /generation target/i }), { target: { value: 'peer-example' } });
fireEvent.change(screen.getByLabelText('Prompt'), { target: { value: 'a lighthouse at dusk' } });

const generate = screen.getByRole('button', { name: /^Generate$/ });
expect(generate).not.toBeDisabled();
await act(async () => { fireEvent.click(generate); });

await waitFor(() => expect(state.generateImage).toHaveBeenCalled());
expect(state.generateImage.mock.calls[0][0]).toMatchObject({ mediaProviderPeerId: 'peer-example' });
});
});