Improve ui and functionality

This commit is contained in:
Jaret Burkett committed 2026-09-10 15:44:00 -06:00
1 parent 449488f6b7
commit 6e1b39c7a8
8 files changed
+610 -123

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/server/prisma';
import { listEngines } from '@/server/inferenceEngine';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const isWindows = process.platform === 'win32';
// Force stop: SIGKILL the job process (tree-kill on Windows) and mark the row
// stopped. For jobs that ignored the graceful stop (a hung model load).
export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) {
const { jobID } = await params;
const job = await prisma.job.findUnique({ where: { id: jobID } });
// the pid from the row, else (inference engines) the one the live process
// published in its engine.json
let pid: number | null = job?.pid ?? null;
if (pid == null) {
const engine = (await listEngines(true)).find(e => e.jobId === jobID);
pid = engine?.endpoint?.pid ?? null;
}
if (!job && pid == null) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
let killed = false;
if (pid != null) {
try {
if (isWindows) {
await execAsync(`taskkill /PID ${pid} /T /F`, { windowsHide: true });
} else {
process.kill(pid, 'SIGKILL');
}
killed = true;
} catch (e: any) {
console.warn(`kill ${jobID} pid ${pid}: ${e?.message || e}`);
}
}
if (job) {
await prisma.job.update({
where: { id: jobID },
data: { stop: true, status: 'stopped', info: 'Job force stopped', pid: null },
});
}
return NextResponse.json({ ok: true, killed });
}
+355 -101
View File
@@ -3,10 +3,10 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button } from '@headlessui/react';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, Play, Square, Sparkles, Trash2, X } from 'lucide-react';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, OctagonX, Play, Square, Sparkles, Trash2, X } from 'lucide-react';
import { openConfirm } from '@/components/ConfirmModal';
import { TopBar, MainContent } from '@/components/layout';
import { Checkbox, CreatableSelectInput, NumberInput, SelectInput, TextAreaInput, TextInput } from '@/components/formInputs';
import { Checkbox, CreatableSelectInput, NumberInput, SelectInput, SliderInput, TextAreaInput, TextInput } from '@/components/formInputs';
import { apiClient } from '@/utils/api';
import { startJob, stopJob } from '@/utils/jobs';
import { startQueue } from '@/utils/queue';
@@ -49,6 +49,7 @@ const qtypeOptions = [
// everything the user set on the page survives a reload
const STORAGE_KEY = 'aitk_generate_page';
const ACTIVE_REQUEST_KEY = 'aitk_generate_active_request';
interface PersistedState {
arch?: string;
model?: { [key: string]: any };
@@ -73,14 +74,29 @@ const authHeaders = (): Record<string, string> => {
return token ? { Authorization: `Bearer ${token}` } : {};
};
function Card({ title, open, onToggle, children }: { title: string; open: boolean; onToggle: () => void; children: React.ReactNode }) {
function Card({
title,
subtitle,
open,
onToggle,
children,
}: {
title: string;
subtitle?: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="bg-gray-900 rounded-xl border border-gray-800">
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between px-4 py-2.5 text-sm text-gray-200 font-semibold">
<span>{title}</span>
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between gap-2 px-3 py-1.5 text-xs text-gray-200 font-semibold">
<span className="flex items-baseline gap-2 min-w-0">
<span>{title}</span>
{subtitle && <span className="text-[11px] font-normal text-gray-500 truncate">{subtitle}</span>}
</span>
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronLeft className="w-4 h-4 text-gray-400" />}
</button>
<div className={open ? 'px-4 pb-4' : 'hidden'}>{children}</div>
<div className={open ? 'px-3 pb-3' : 'hidden'}>{children}</div>
</div>
);
}
@@ -100,6 +116,12 @@ function GeneratePageInner() {
// the engine is actually up (or the job row reports a failure)
const [startingJobId, setStartingJobId] = useState<string | null>(null);
const [stoppingJobId, setStoppingJobId] = useState<string | null>(null);
const [stoppingSince, setStoppingSince] = useState<number | null>(null);
const [now, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const engineJobId = preferredJob || engineStatus?.engine?.jobId || null;
const proxy = useCallback((path: string) => `/api/inference/${path}${engineJobId ? `${path.includes('?') ? '&' : '?'}job=${engineJobId}` : ''}`, [engineJobId]);
@@ -125,6 +147,7 @@ function GeneratePageInner() {
}
if (stoppingJobId && !data.engines.some(e => e.jobId === stoppingJobId)) {
setStoppingJobId(null);
setStoppingSince(null);
}
if (data.running) {
return apiClient
@@ -167,12 +190,28 @@ function GeneratePageInner() {
}
};
// Stop = graceful stop, then a hard kill if the process is still around a
// few seconds later (a model load in progress can hold the shutdown up)
const stopEngine = async () => {
if (!engineStatus?.engine) return;
const eng = engineStatus?.engine;
if (!eng) return;
const id = eng.jobId;
setEngineBusy(true);
setStoppingJobId(id);
setStoppingSince(Date.now());
try {
await stopJob(engineStatus.engine.jobId);
setStoppingJobId(engineStatus.engine.jobId);
await stopJob(id).catch(e => console.warn('graceful stop failed, will kill', e?.message || e));
const deadline = Date.now() + 4000;
let alive = true;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 750));
const st = await apiClient.get(`/api/inference/status?job=${id}`).then(r => r.data).catch(() => null);
alive = !!st?.engines?.some((e: any) => e.jobId === id && (e.ready || e.status === 'stopping'));
if (!alive) break;
}
if (alive) {
await apiClient.get(`/api/jobs/${id}/kill`);
}
} catch (e: any) {
alert(`Failed to stop the engine: ${e?.response?.data?.error || e?.message || e}`);
} finally {
@@ -180,8 +219,43 @@ function GeneratePageInner() {
}
};
const isStarting = engineBusy || !!startingJobId || (!!engineStatus?.engine && !engineStatus.running && !stoppingJobId);
const isStopping = !!stoppingJobId;
// a row that is stopped but whose process is still alive shows as 'stopping' from the status route
const isStopping = !!stoppingJobId || engineStatus?.engine?.status === 'stopping';
const isStarting = !isStopping && (engineBusy || !!startingJobId || (!!engineStatus?.engine && !engineStatus.running));
const stoppingForMs = stoppingSince ? now - stoppingSince : isStopping ? 999_999 : 0;
// cancel a start that is not getting anywhere: a queued job (no process)
// is just marked stopped; a launched one gets the normal stop
const [startingSince, setStartingSince] = useState<number | null>(null);
useEffect(() => {
if (isStarting && startingSince === null) setStartingSince(Date.now());
if (!isStarting && startingSince !== null) setStartingSince(null);
}, [isStarting, startingSince]);
const cancelStart = async () => {
const eng = engineStatus?.engine;
const id = startingJobId || eng?.jobId;
if (!id) return;
try {
if (!eng || eng.status === 'queued') {
await apiClient.get(`/api/jobs/${id}/mark_stopped`);
} else {
await stopJob(id);
}
} catch (e: any) {
alert(`Cancel failed: ${e?.response?.data?.error || e?.message || e}`);
}
setStartingJobId(null);
setStartingSince(null);
};
const forceStop = async () => {
const id = stoppingJobId || engineStatus?.engine?.jobId;
if (!id) return;
if (!confirm('Force stop kills the engine process immediately. Continue?')) return;
try {
await apiClient.get(`/api/jobs/${id}/kill`);
} catch (e: any) {
alert(`Force stop failed: ${e?.response?.data?.error || e?.message || e}`);
}
};
// ---- models: the same arch list the training UI uses (jobs/new/options.tsx) ----
const archs: GenerateDefaults[] = useMemo(() => modelArchs.map(getGenerateDefaults), []);
@@ -302,15 +376,121 @@ function GeneratePageInner() {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(scratch, 0, 0, w, h);
// the live tile in the history strip mirrors the preview
const thumb = thumbRef.current;
if (thumb) {
const size = 64;
const scaleT = Math.min(size / w, size / h);
const tw = Math.max(1, Math.round(w * scaleT));
const th = Math.max(1, Math.round(h * scaleT));
if (thumb.width !== tw || thumb.height !== th) {
thumb.width = tw;
thumb.height = th;
}
const tctx = thumb.getContext('2d');
if (tctx) {
tctx.imageSmoothingEnabled = true;
tctx.drawImage(scratch, 0, 0, tw, th);
}
}
};
const thumbRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => drawFrame(frameIdx), [frameIdx]);
const generate = async () => {
if (!ready || running) return;
// temporal latents auto-play: T latent frames span num_frames / fps
// seconds, so each latent frame shows for that fraction of the clip
const clipRef = useRef<{ num_frames: number; fps: number } | null>(null);
useEffect(() => {
if (frameCount <= 1 || !showPreview) return;
const clip = clipRef.current;
const seconds = clip && clip.num_frames > 1 && clip.fps > 0 ? clip.num_frames / clip.fps : frameCount / 4;
const msPerFrame = Math.max(30, (seconds * 1000) / frameCount);
const t = setInterval(() => setFrameIdx(i => (i + 1) % frameCount), msPerFrame);
return () => clearInterval(t);
}, [frameCount, showPreview]);
// one consumer for both a fresh /generate response and a /stream/{id}
// reattach: everything the stage needs comes from the frames themselves
const consumeStream = async (res: Response, abort: AbortController) => {
let preview: PreviewInfo | null = null;
let startInfo: { arch: string; prompt: string } = { arch, prompt: sample.prompt || '' };
// only the first latent of a run pulls the stage to the preview; after
// that the user may browse history and come back via the live tile
let shownPreview = false;
await readEngineFrames(
res,
({ header, payload }) => {
requestIdRef.current = header.request_id;
switch (header.type) {
case 'start':
preview = header.preview;
startInfo = { arch: header.model?.arch || arch, prompt: header.sample?.prompt ?? '' };
if (header.sample?.width && header.sample?.height) {
previewSizeRef.current = { width: header.sample.width, height: header.sample.height };
}
clipRef.current = { num_frames: header.sample?.num_frames ?? 1, fps: header.sample?.fps ?? 16 };
setStatusLine(`Generating with ${startInfo.arch}`);
break;
case 'status':
setStatusLine(header.message);
break;
case 'progress':
setProgress({ step: header.step, total: header.total, elapsed: header.elapsed });
break;
case 'latent': {
try {
if (header.preview) preview = header.preview;
const data = payloadToFloat32(header, payload);
const img = latentToImage(header, data, preview);
if (img) {
framesRef.current = img.frameData.map(px => new ImageData(px, img.width, img.height));
setFrameCount(img.frames);
drawFrame(frameIdx);
if (!shownPreview) {
shownPreview = true;
setShowPreview(true);
}
}
} catch (e) {
console.warn('latent preview failed', e);
}
break;
}
case 'result': {
const item: ResultItem = {
request_id: header.request_id,
path: header.path,
kind: header.kind,
ext: header.ext,
seed: header.seed,
width: header.width,
height: header.height,
seconds: header.seconds,
steps: header.steps,
prompt: startInfo.prompt,
arch: startInfo.arch,
};
setResults(r => (r.some(x => x.path === item.path) ? r : [item, ...r]));
setSelected(item);
setShowPreview(false);
break;
}
case 'error':
setError(header.cancelled ? 'Cancelled' : header.message);
break;
case 'end':
setStatusLine(header.status === 'done' ? 'Done' : header.status);
break;
}
},
abort.signal,
);
};
const beginRun = () => {
setRunning(true);
setError(null);
setProgress(null);
setStatusLine('Submitting');
framesRef.current = [];
setFrameCount(1);
setFrameIdx(0);
@@ -318,14 +498,31 @@ function GeneratePageInner() {
// arrives; wipe the stale bitmap so an empty canvas never shows old data
const cv = canvasRef.current;
if (cv) cv.getContext('2d')?.clearRect(0, 0, cv.width, cv.height);
previewSizeRef.current = sample.width && sample.height ? { width: sample.width, height: sample.height } : null;
const abort = new AbortController();
abortRef.current = abort;
let preview: PreviewInfo | null = null;
return abort;
};
const endRun = () => {
setRunning(false);
abortRef.current = null;
requestIdRef.current = null;
try {
localStorage.removeItem(ACTIVE_REQUEST_KEY);
} catch {
// storage unavailable
}
};
const generate = async () => {
if (!ready || running) return;
const abort = beginRun();
setStatusLine('Submitting');
previewSizeRef.current = sample.width && sample.height ? { width: sample.width, height: sample.height } : null;
const body: { model: any; sample: any; stream: any } = {
model: { ...model, arch },
sample: { ...sample, seed: sample.seed === '' ? -1 : sample.seed },
stream: { latents: 'raw', every_n_steps: 1, max_frames: 8 },
stream: { latents: 'raw', every_n_steps: 1, max_frames: 0 },
};
try {
const res = await fetch(proxy('generate'), {
@@ -335,74 +532,73 @@ function GeneratePageInner() {
signal: abort.signal,
});
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
await readEngineFrames(
res,
({ header, payload }) => {
requestIdRef.current = header.request_id;
switch (header.type) {
case 'start':
preview = header.preview;
if (header.sample?.width && header.sample?.height) {
previewSizeRef.current = { width: header.sample.width, height: header.sample.height };
}
setStatusLine(`Generating with ${header.model?.arch}`);
break;
case 'status':
setStatusLine(header.message);
break;
case 'progress':
setProgress({ step: header.step, total: header.total, elapsed: header.elapsed });
break;
case 'latent': {
try {
if (header.preview) preview = header.preview;
const data = payloadToFloat32(header, payload);
const img = latentToImage(header, data, preview);
if (img) {
framesRef.current = img.frameData.map(px => new ImageData(px, img.width, img.height));
setFrameCount(img.frames);
drawFrame(frameIdx);
setShowPreview(true);
}
} catch (e) {
console.warn('latent preview failed', e);
}
break;
}
case 'result': {
const item: ResultItem = {
request_id: header.request_id,
path: header.path,
kind: header.kind,
ext: header.ext,
seed: header.seed,
width: header.width,
height: header.height,
seconds: header.seconds,
steps: header.steps,
prompt: body.sample.prompt,
arch,
};
setResults(r => [item, ...r]);
setSelected(item);
setShowPreview(false);
break;
}
case 'error':
setError(header.cancelled ? 'Cancelled' : header.message);
break;
case 'end':
setStatusLine(header.status === 'done' ? 'Done' : header.status);
break;
}
},
abort.signal,
);
// remembered so a reload can reattach to this run
const id = res.headers.get('x-request-id');
if (id) {
try {
localStorage.setItem(ACTIVE_REQUEST_KEY, id);
} catch {
// storage unavailable
}
}
await consumeStream(res, abort);
} catch (e: any) {
if (e?.name !== 'AbortError') setError(e?.message || String(e));
} finally {
setRunning(false);
abortRef.current = null;
endRun();
}
};
// attach to a request already running on the engine (after a reload, or a
// run started from another tab): replays its frames, then follows live
const attachedRef = useRef<Set<string>>(new Set());
const attachTo = async (id: string) => {
if (running || attachedRef.current.has(id)) return;
attachedRef.current.add(id);
const abort = beginRun();
requestIdRef.current = id;
setStatusLine('Reattaching…');
try {
const res = await fetch(proxy(`stream/${id}`), { headers: authHeaders(), signal: abort.signal });
if (!res.ok) throw new Error(res.status === 404 ? 'previous generation is gone' : `HTTP ${res.status}`);
await consumeStream(res, abort);
} catch (e: any) {
if (e?.name !== 'AbortError') setError(e?.message || String(e));
} finally {
endRun();
}
};
const attachRef = useRef(attachTo);
attachRef.current = attachTo;
// 1) the id we stored when we started a run (covers a job still queued)
useEffect(() => {
if (!ready || running) return;
let id: string | null = null;
try {
id = localStorage.getItem(ACTIVE_REQUEST_KEY);
} catch {
id = null;
}
if (id) attachRef.current(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready]);
// 2) whatever the engine says it is generating right now
const busyRequestId: string | null = health?.current?.request_id || null;
useEffect(() => {
if (ready && !running && busyRequestId) attachRef.current(busyRequestId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, running, busyRequestId]);
// engine busy with a run we could not attach to (e.g. an engine older than
// /stream): still let the user cancel it
const cancelBusy = async () => {
if (!busyRequestId) return;
try {
await apiClient.post(proxy(`cancel/${busyRequestId}`));
} catch (e: any) {
alert(`Cancel failed: ${e?.response?.data?.error || e?.message || e}`);
}
};
@@ -459,9 +655,16 @@ function GeneratePageInner() {
<div className="flex-1" />
<div className="flex items-center gap-2 text-xs sm:text-sm">
{isStopping ? (
<Button disabled className="px-2 py-1 rounded-md bg-red-900 text-white/70 flex items-center gap-1 cursor-wait">
<Loader2 className="w-4 h-4 animate-spin" /> Stopping engine…
</Button>
<>
<Button disabled className="px-2 py-1 rounded-md bg-red-900 text-white/70 flex items-center gap-1 cursor-wait">
<Loader2 className="w-4 h-4 animate-spin" /> Stopping engine…
</Button>
{stoppingForMs > 8000 && (
<Button onClick={forceStop} className="px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 text-white flex items-center gap-1" title="Kill the engine process">
<OctagonX className="w-4 h-4" /> Force stop
</Button>
)}
</>
) : ready ? (
<>
<span className="text-green-400">Engine running</span>
@@ -488,10 +691,20 @@ function GeneratePageInner() {
: 'Start the inference engine on a GPU to generate images, video, and audio with any model.'}
</p>
{isStarting ? (
<Button disabled className="w-full px-3 py-2 rounded-md bg-blue-900 text-white/70 flex items-center justify-center gap-2 cursor-wait">
<Loader2 className="w-4 h-4 animate-spin" />
{engineStatus?.engine?.status === 'running' ? 'Engine loading…' : engineStatus?.engine ? `Engine ${engineStatus.engine.status}…` : 'Starting engine…'}
</Button>
<div className="flex flex-col gap-2">
<Button disabled className="w-full px-3 py-2 rounded-md bg-blue-900 text-white/70 flex items-center justify-center gap-2 cursor-wait">
<Loader2 className="w-4 h-4 animate-spin" />
{engineStatus?.engine?.status === 'running' ? 'Engine loading…' : engineStatus?.engine ? `Engine ${engineStatus.engine.status}…` : 'Starting engine…'}
</Button>
{engineStatus?.engine?.status === 'queued' && (
<div className="text-[11px] text-gray-500">Waiting for the GPU queue to pick the job up. If the queue is stopped, start it from the Queue page or cancel here.</div>
)}
{startingSince !== null && now - startingSince > 5000 && (
<Button onClick={cancelStart} className="w-full px-3 py-1.5 rounded-md bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm">
Cancel
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-3">
{!isMac() && (
@@ -537,6 +750,17 @@ function GeneratePageInner() {
Cancel
</Button>
)}
{!running && busyRequestId && (
<>
<span className="text-amber-300 truncate">
Engine busy: {health.current.arch}
{health.current.total_steps ? ` ${health.current.step}/${health.current.total_steps}` : ''}
</span>
<Button onClick={cancelBusy} className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs">
Cancel
</Button>
</>
)}
</div>
{progress?.total ? (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gray-800 z-10">
@@ -552,9 +776,6 @@ function GeneratePageInner() {
<div className="absolute text-gray-600 text-sm">Your generation will appear here</div>
)}
</div>
{frameCount > 1 && showPreview && (
<input type="range" min={0} max={frameCount - 1} value={frameIdx} onChange={e => setFrameIdx(parseInt(e.target.value))} className="absolute bottom-3 left-1/2 -translate-x-1/2 w-1/2 z-10" />
)}
{/* selected result replaces the preview */}
{!showPreview && selected && (
@@ -581,7 +802,20 @@ function GeneratePageInner() {
{/* history strip */}
<div className="h-20 shrink-0 m-2 flex items-center gap-2">
<div className="flex-1 min-w-0 h-full flex items-center gap-2 overflow-x-auto overflow-y-hidden">
{results.length === 0 ? (
{running && (
<button
type="button"
onClick={() => setShowPreview(true)}
title="Back to the live preview"
className={`h-16 w-16 shrink-0 rounded-md overflow-hidden border-2 ${showPreview ? 'border-blue-500' : 'border-transparent hover:border-gray-600'} bg-gray-800 relative flex items-center justify-center`}
>
<canvas ref={thumbRef} className="max-w-full max-h-full" />
<span className="absolute bottom-0.5 right-0.5 rounded bg-gray-950/80 p-0.5">
<Loader2 className="w-3 h-3 animate-spin text-blue-400" />
</span>
</button>
)}
{results.length === 0 && !running ? (
<div className="text-[11px] text-gray-600 px-1">History</div>
) : (
results.map(r => {
@@ -636,7 +870,7 @@ function GeneratePageInner() {
</div>
{/* ---- right: collapsible model + prompt sidebar ---- */}
<div className={`relative shrink-0 transition-[width] duration-200 ${sidebarOpen ? 'w-[380px]' : 'w-0'}`}>
<div className={`relative shrink-0 transition-[width] duration-200 ${sidebarOpen ? 'w-[340px]' : 'w-0'}`}>
<button
type="button"
onClick={() => setSidebarOpen(o => !o)}
@@ -646,9 +880,9 @@ function GeneratePageInner() {
{sidebarOpen ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
</button>
<div className={`h-full flex flex-col border-l border-gray-800 bg-gray-900/60 ${sidebarOpen ? '' : 'hidden'}`}>
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
<div className="compact-form flex-1 min-h-0 overflow-y-auto p-2 space-y-2">
<Card title="Model" open={cardOpen('model')} onToggle={() => toggleCard('model')}>
<Card title="Model" subtitle={entry?.label || arch} open={cardOpen('model')} onToggle={() => toggleCard('model')}>
<SelectInput label="Architecture" value={arch} onChange={v => applyArch(v as string)} options={archOptions} disabled={!ready} />
<CreatableSelectInput
label="Name or Path"
@@ -660,7 +894,7 @@ function GeneratePageInner() {
{'extras_name_or_path' in (entry?.model || {}) && (
<TextInput label="Extras Name or Path" value={model.extras_name_or_path || ''} onChange={v => setModel(m => ({ ...m, extras_name_or_path: v }))} />
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-2">
<SelectInput
label="Quantize Transformer"
value={model.quantize ? model.qtype || 'convrot8' : ''}
@@ -674,10 +908,30 @@ function GeneratePageInner() {
options={[{ value: '', label: 'None (bf16)' }, ...qtypeOptions]}
/>
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div className="grid grid-cols-2 gap-2 mt-2">
<Checkbox label="Low VRAM" checked={!!model.low_vram} onChange={v => setModel(m => ({ ...m, low_vram: v }))} />
<Checkbox label="Layer offloading" checked={!!model.layer_offloading} onChange={v => setModel(m => ({ ...m, layer_offloading: v }))} />
</div>
{model.layer_offloading && (
<div className="mt-2 space-y-1">
<SliderInput
label="Transformer Offload %"
value={Math.round((model.layer_offloading_transformer_percent ?? 1) * 100)}
onChange={v => setModel(m => ({ ...m, layer_offloading_transformer_percent: v * 0.01 }))}
min={0}
max={100}
step={1}
/>
<SliderInput
label="Text Encoder Offload %"
value={Math.round((model.layer_offloading_text_encoder_percent ?? 1) * 100)}
onChange={v => setModel(m => ({ ...m, layer_offloading_text_encoder_percent: v * 0.01 }))}
min={0}
max={100}
step={1}
/>
</div>
)}
</Card>
<Card title="Prompt" open={cardOpen('prompt')} onToggle={() => toggleCard('prompt')}>
@@ -685,7 +939,7 @@ function GeneratePageInner() {
{(sample.guidance_scale ?? 4) > 1 && (
<TextAreaInput label="Negative prompt" value={sample.negative_prompt || ''} onChange={v => setSample(s => ({ ...s, negative_prompt: v }))} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-2">
<NumberInput label="Width" value={sample.width ?? 1024} onChange={v => setSample(s => ({ ...s, width: v }))} min={64} max={4096} />
<NumberInput label="Height" value={sample.height ?? 1024} onChange={v => setSample(s => ({ ...s, height: v }))} min={64} max={4096} />
<NumberInput label="Steps" value={sample.num_inference_steps ?? 25} onChange={v => setSample(s => ({ ...s, num_inference_steps: v }))} min={1} max={200} />
@@ -702,11 +956,11 @@ function GeneratePageInner() {
</div>
{/* always-visible action bar */}
<div className="shrink-0 p-3 border-t border-gray-800 bg-gray-900 flex gap-2">
<div className="shrink-0 p-2 border-t border-gray-800 bg-gray-900 flex gap-2">
<Button
onClick={generate}
disabled={!ready || running || !model.name_or_path}
className="flex-1 px-3 py-2 rounded-md bg-blue-700 hover:bg-blue-600 disabled:opacity-40 text-white flex items-center justify-center gap-2"
className="flex-1 px-3 py-1.5 rounded-md bg-blue-700 hover:bg-blue-600 disabled:opacity-40 text-white text-sm flex items-center justify-center gap-2"
title="Ctrl/Cmd + Enter"
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} Generate
+22
View File
@@ -129,3 +129,25 @@ body {
@apply text-sm text-gray-200;
}
}
/* Generate page sidebar: tighter version of the shared form inputs */
.compact-form label {
margin-top: 0.25rem !important;
margin-bottom: 0.125rem !important;
font-size: 11px !important;
}
.compact-form input[type='text'],
.compact-form input[type='number'],
.compact-form input[type='password'],
.compact-form textarea {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
font-size: 12px !important;
}
.compact-form textarea {
min-height: 3.5rem;
}
.compact-form [class*='control'] {
min-height: 30px !important;
font-size: 12px !important;
}
+17 -11
View File
@@ -52,18 +52,24 @@ const CACHE_MS = 2000;
/** Every inference-engine job that is running (or starting), with its endpoint when published. */
export async function listEngines(forceFresh = false): Promise<EngineHandle[]> {
if (!forceFresh && cache && Date.now() - cache.ts < CACHE_MS) return cache.handles;
const jobs = await prisma.job.findMany({
where: { job_type: 'inference', status: { in: ['running', 'queued', 'stopping'] } },
orderBy: { updated_at: 'desc' },
});
// every inference job: a row already marked stopped can still be a live
// process finishing (or ignoring) its shutdown, which engine.json's pid tells us
const jobs = await prisma.job.findMany({ where: { job_type: 'inference' }, orderBy: { updated_at: 'desc' } });
const trainingFolder = await getTrainingFolder();
const handles: EngineHandle[] = jobs.map(job => ({
jobId: job.id,
jobName: job.name,
gpuIds: job.gpu_ids,
status: job.status,
endpoint: job.status === 'running' ? readEndpoint(path.join(trainingFolder, job.name)) : null,
}));
const handles: EngineHandle[] = [];
for (const job of jobs) {
const active = ['running', 'queued', 'stopping'].includes(job.status);
const endpoint = readEndpoint(path.join(trainingFolder, job.name));
if (!active && !endpoint) continue;
handles.push({
jobId: job.id,
jobName: job.name,
gpuIds: job.gpu_ids,
// process still alive after the row was stopped: report it as stopping
status: !active && endpoint ? 'stopping' : job.status,
endpoint,
});
}
cache = { ts: Date.now(), handles };
return handles;
}