Gregorfun's picture
Initial commit
32c5da4
import { useEffect, useMemo, useRef, useState } from "react";
type JobStatus = "queued" | "running" | "done" | "error" | "cancelled";
type ModelInfo = {
id: string;
name: string;
available: boolean;
description: string;
};
type JobInfo = {
job_id: string;
status: JobStatus;
progress: number;
message: string;
image_paths: string[];
output_dir: string | null;
error: string | null;
};
type HistoryItem = {
prompt: string;
negative_prompt: string;
timestamp: string;
};
type Preset = {
name: string;
prompt: string;
negative_prompt: string;
model: string;
size: string;
count: number;
steps: number;
guidance: number;
image_type: string;
style_preset: string;
style_strength: number;
updated_at: string;
};
type DashboardStats = {
queued: number;
running: number;
done: number;
error: number;
cancelled: number;
total: number;
last_24h: number;
};
type AdminSettings = {
content_profile: string;
rate_limit_per_minute: number;
output_retention_days: number;
adult_enabled: boolean;
};
const API_BASE = "http://127.0.0.1:8008";
function toImageUrl(path: string): string {
return `${API_BASE}/image?path=${encodeURIComponent(path)}`;
}
type ResolvedImageProps = {
path: string;
alt: string;
};
function ResolvedImage({ path, alt }: ResolvedImageProps) {
const [src, setSrc] = useState(() => toImageUrl(path));
useEffect(() => {
let active = true;
setSrc(toImageUrl(path));
void window.imageForge.readImageDataUrl(path).then((dataUrl) => {
if (active && dataUrl) {
setSrc(dataUrl);
}
});
return () => {
active = false;
};
}, [path]);
return <img src={src} alt={alt} />;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const apiKey = localStorage.getItem("imageforge_api_key") || "";
const response = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"X-ImageForge-Api-Key": apiKey,
...(init?.headers || {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return (await response.json()) as T;
}
function App() {
const [activeTab, setActiveTab] = useState<"studio" | "dashboard" | "presets" | "settings">("studio");
const [apiKeyInput, setApiKeyInput] = useState(localStorage.getItem("imageforge_api_key") || "");
const [adminSettings, setAdminSettings] = useState<AdminSettings | null>(null);
const lastJobLogSignatureRef = useRef<string>("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [presets, setPresets] = useState<Preset[]>([]);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [prompt, setPrompt] = useState("");
const [batchPrompts, setBatchPrompts] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [model, setModel] = useState("dummy");
const [modelVariant, setModelVariant] = useState("");
const [size, setSize] = useState("512x512");
const [count, setCount] = useState(1);
const [randomSeed, setRandomSeed] = useState(true);
const [seed, setSeed] = useState<number>(42);
const [steps, setSteps] = useState(18);
const [guidance, setGuidance] = useState(6.5);
const [imageType, setImageType] = useState("general");
const [stylePreset, setStylePreset] = useState("auto");
const [styleStrength, setStyleStrength] = useState(60);
const [initImagePath, setInitImagePath] = useState<string | null>(null);
const [img2imgStrength, setImg2imgStrength] = useState(0.45);
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
const [jobs, setJobs] = useState<JobInfo[]>([]);
const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [compareImages, setCompareImages] = useState<string[]>([]);
async function refreshModels() {
const response = await api<{ value: ModelInfo[]; Count: number }>("/models");
const data = Array.isArray(response) ? response : response.value || [];
setModels(data);
const preferred = ["a1111", "localai", "diffusion", "dummy"];
const available =
preferred
.map((id) => data.find((m) => m.id === id && m.available))
.find(Boolean) ||
data.find((m) => m.available) ||
data[0];
if (available) {
setModel((prev) => {
const current = data.find((m) => m.id === prev);
if (!current || !current.available || prev === "dummy") {
return available.id;
}
return prev;
});
}
}
async function refreshHistory() {
const data = await api<HistoryItem[]>("/history");
setHistory(data);
}
async function refreshJobs() {
const data = await api<JobInfo[]>("/jobs");
setJobs(data);
}
async function refreshPresets() {
const data = await api<Preset[]>("/presets");
setPresets(data);
}
async function refreshStats() {
const data = await api<DashboardStats>("/dashboard/stats");
setStats(data);
}
async function refreshAdminSettings() {
try {
const data = await api<AdminSettings>("/admin/settings");
setAdminSettings(data);
} catch {
setAdminSettings(null);
}
}
useEffect(() => {
void Promise.all([refreshModels(), refreshHistory(), refreshJobs(), refreshPresets(), refreshStats(), refreshAdminSettings()]).catch((err: Error) => {
void window.imageForge.showError("Startup Error", err.message);
});
}, []);
useEffect(() => {
const timer = window.setInterval(() => {
void refreshJobs().catch(() => {});
void refreshStats().catch(() => {});
}, 2000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!currentJobId) {
return;
}
const timer = window.setInterval(() => {
void api<JobInfo>(`/jobs/${currentJobId}`)
.then((info) => {
setJobInfo(info);
const signature = `${info.status}|${info.progress}|${info.message}`;
if (lastJobLogSignatureRef.current !== signature) {
lastJobLogSignatureRef.current = signature;
setLogs((prev) => {
const next = [...prev, `${new Date().toLocaleTimeString()} | ${info.message}`];
return next.slice(-200);
});
}
if (info.status === "done") {
setSelectedImage(info.image_paths[0] || null);
setCurrentJobId(null);
void refreshHistory().catch(() => {});
void refreshJobs().catch(() => {});
}
if (info.status === "error" || info.status === "cancelled") {
setCurrentJobId(null);
if (info.error) {
void window.imageForge.showError("Generation Error", info.error);
}
}
})
.catch((err: Error) => {
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Poll failed: ${err.message}`]);
});
}, 1000);
return () => window.clearInterval(timer);
}, [currentJobId]);
const isGenerating = useMemo(
() => Boolean(currentJobId) || jobInfo?.status === "queued" || jobInfo?.status === "running",
[currentJobId, jobInfo?.status]
);
function buildPayload(basePrompt: string) {
return {
prompt: basePrompt,
negative_prompt: negativePrompt,
model,
model_variant: modelVariant.trim() || null,
size,
count,
random_seed: randomSeed,
seed: randomSeed ? null : seed,
steps,
guidance,
image_type: imageType,
style_preset: stylePreset,
style_strength: styleStrength,
init_image_path: initImagePath,
img2img_strength: img2imgStrength,
};
}
async function submitOne(basePrompt: string) {
const response = await api<{ job_id: string }>("/generate", {
method: "POST",
body: JSON.stringify(buildPayload(basePrompt)),
});
return response.job_id;
}
async function handleGenerate() {
if (!prompt.trim()) {
await window.imageForge.showError("Validation", "Prompt darf nicht leer sein.");
return;
}
try {
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Submit generation`]);
setJobInfo(null);
lastJobLogSignatureRef.current = "";
setSelectedImage(null);
const jobId = await submitOne(prompt.trim());
setCurrentJobId(jobId);
await refreshJobs();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await window.imageForge.showError("Generate Failed", message);
}
}
async function handleBatchGenerate() {
const lines = batchPrompts
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
await window.imageForge.showError("Validation", "Batch-Prompts sind leer.");
return;
}
try {
for (const line of lines) {
await submitOne(line);
}
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Batch queued (${lines.length})`]);
await refreshJobs();
await refreshStats();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await window.imageForge.showError("Batch Failed", message);
}
}
async function handleCancel() {
if (!currentJobId) {
return;
}
await api(`/jobs/${currentJobId}/cancel`, { method: "POST" });
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Cancel requested`]);
await refreshJobs();
}
async function handleCancelById(jobId: string) {
await api(`/jobs/${jobId}/cancel`, { method: "POST" });
await refreshJobs();
}
async function handleRetry(jobId: string) {
const out = await api<{ new_job_id: string }>(`/jobs/${jobId}/retry`, { method: "POST" });
setCurrentJobId(out.new_job_id);
await refreshJobs();
}
async function handleSaveImage() {
if (!selectedImage) {
return;
}
const ok = await window.imageForge.saveImage(selectedImage, "imageforge_output.png");
if (ok) {
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Image saved`]);
}
}
async function handleExportImage(format: "png" | "jpg" | "webp") {
if (!selectedImage) {
return;
}
const out = await api<{ output_path: string }>("/export", {
method: "POST",
body: JSON.stringify({ source_path: selectedImage, format, quality: 92 }),
});
await window.imageForge.saveImage(out.output_path, `imageforge_export.${format}`);
}
async function handleOpenFolder() {
if (jobInfo?.output_dir) {
await window.imageForge.openFolder(jobInfo.output_dir);
}
}
async function handlePickInitImage() {
const path = await window.imageForge.pickImage();
if (path) {
setInitImagePath(path);
}
}
function toggleCompare(path: string) {
setCompareImages((prev) => (prev.includes(path) ? prev.filter((p) => p !== path) : [...prev.slice(-3), path]));
}
async function savePreset() {
const name = prompt.slice(0, 40) || `preset-${Date.now()}`;
const payload = {
name,
prompt,
negative_prompt: negativePrompt,
model,
size,
count,
steps,
guidance,
image_type: imageType,
style_preset: stylePreset,
style_strength: styleStrength,
};
await api("/presets", { method: "POST", body: JSON.stringify(payload) });
await refreshPresets();
}
function applyHyperrealPortraitPreset() {
setImageType("portrait");
setStylePreset("photorealistic");
setStyleStrength(90);
setSteps(48);
setGuidance(7.0);
setModel((prev) => prev || "localai");
setNegativePrompt(
"low quality, blurry, bad anatomy, extra fingers, waxy skin, overprocessed face, watermark"
);
}
function applyHyperrealProductPreset() {
setImageType("product");
setStylePreset("photorealistic");
setStyleStrength(85);
setSteps(42);
setGuidance(6.5);
setModel((prev) => prev || "localai");
setNegativePrompt(
"low quality, blurry, noisy, distorted geometry, warped label, watermark, text clutter"
);
}
async function applyPreset(p: Preset) {
setPrompt(p.prompt);
setNegativePrompt(p.negative_prompt);
setModel(p.model);
setSize(p.size);
setCount(p.count);
setSteps(p.steps);
setGuidance(p.guidance);
setImageType(p.image_type);
setStylePreset(p.style_preset);
setStyleStrength(p.style_strength);
setActiveTab("studio");
}
async function deletePreset(name: string) {
await api(`/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
await refreshPresets();
}
async function saveAdminSettings() {
if (!adminSettings) {
return;
}
const out = await api<AdminSettings>("/admin/settings", {
method: "PUT",
body: JSON.stringify(adminSettings),
});
setAdminSettings(out);
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Admin settings updated`]);
}
return (
<div className="app-grid">
<aside className="sidebar">
<h2>Control</h2>
<div className="tabs">
<button className={activeTab === "studio" ? "tab-active" : ""} onClick={() => setActiveTab("studio")}>Studio</button>
<button className={activeTab === "dashboard" ? "tab-active" : ""} onClick={() => setActiveTab("dashboard")}>Dashboard</button>
<button className={activeTab === "presets" ? "tab-active" : ""} onClick={() => setActiveTab("presets")}>Presets</button>
<button className={activeTab === "settings" ? "tab-active" : ""} onClick={() => setActiveTab("settings")}>Settings</button>
</div>
<label>
API Key
<div className="row-actions">
<input value={apiKeyInput} onChange={(e) => setApiKeyInput(e.target.value)} />
<button
type="button"
onClick={() => {
localStorage.setItem("imageforge_api_key", apiKeyInput.trim());
void Promise.all([refreshModels(), refreshJobs(), refreshStats()]).catch(() => {});
}}
>
Save
</button>
</div>
</label>
<label>
Model
<select value={model} onChange={(e) => setModel(e.target.value)}>
{models.map((m) => (
<option key={m.id} value={m.id} disabled={!m.available}>
{m.name} {m.available ? "" : "(not available)"}
</option>
))}
</select>
</label>
<label>
Model Variant / Checkpoint
<input value={modelVariant} onChange={(e) => setModelVariant(e.target.value)} placeholder="optional model id" />
</label>
<label>
Size
<select value={size} onChange={(e) => setSize(e.target.value)}>
<option value="512x512">512x512 (fast)</option>
<option value="768x768">768x768</option>
<option value="1024x1024">1024x1024</option>
<option value="1024x1536">1024x1536</option>
<option value="1536x1024">1536x1024</option>
</select>
</label>
<label>
Count
<input type="number" min={1} max={4} value={count} onChange={(e) => setCount(Number(e.target.value) || 1)} />
</label>
<label className="row">
<input type="checkbox" checked={randomSeed} onChange={(e) => setRandomSeed(e.target.checked)} /> Random Seed
</label>
<label>
Seed
<input type="number" disabled={randomSeed} value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} />
</label>
<label>
Steps / Quality: {steps}
<input type="range" min={1} max={100} value={steps} onChange={(e) => setSteps(Number(e.target.value))} />
</label>
<label>
Guidance / Creativity: {guidance.toFixed(1)}
<input type="range" min={1} max={20} step={0.5} value={guidance} onChange={(e) => setGuidance(Number(e.target.value))} />
</label>
<label>
Bildtyp
<select value={imageType} onChange={(e) => setImageType(e.target.value)}>
<option value="general">Allgemein</option>
<option value="photo">Foto</option>
<option value="portrait">Portrait</option>
<option value="landscape">Landschaft</option>
<option value="architecture">Architektur</option>
<option value="product">Produkt</option>
<option value="logo">Logo</option>
<option value="icon">Icon</option>
<option value="poster">Poster</option>
<option value="illustration">Illustration</option>
<option value="anime">Anime</option>
<option value="pixel_art">Pixel Art</option>
<option value="sketch">Skizze</option>
<option value="painting">Painting</option>
<option value="3d">3D Render</option>
</select>
</label>
<label>
Stil
<select value={stylePreset} onChange={(e) => setStylePreset(e.target.value)}>
<option value="auto">Auto</option>
<option value="photorealistic">Photorealistisch</option>
<option value="cinematic">Cinematic</option>
<option value="minimal">Minimal</option>
<option value="vibrant">Vibrant</option>
<option value="monochrome">Monochrom</option>
<option value="watercolor">Watercolor</option>
<option value="oil">Oil Painting</option>
<option value="noir">Noir</option>
<option value="fantasy">Fantasy</option>
</select>
</label>
<label>
Stil-Staerke: {styleStrength}%
<input type="range" min={0} max={100} step={5} value={styleStrength} onChange={(e) => setStyleStrength(Number(e.target.value))} />
</label>
<label>
Input-Bild (Img2Img)
<div className="row-actions">
<button type="button" onClick={handlePickInitImage}>Bild waehlen</button>
<button type="button" onClick={() => setInitImagePath(null)} disabled={!initImagePath}>Entfernen</button>
</div>
{initImagePath ? <small>{initImagePath}</small> : <small>Kein Startbild gesetzt</small>}
</label>
<label>
Img2Img Staerke: {img2imgStrength.toFixed(2)}
<input type="range" min={0} max={1} step={0.05} value={img2imgStrength} onChange={(e) => setImg2imgStrength(Number(e.target.value))} disabled={!initImagePath} />
</label>
</aside>
<main className="main-area">
{activeTab === "studio" && (
<>
<h1>ImageForge Studio</h1>
<label>
Prompt
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={5} placeholder="Describe your image..." />
</label>
<label>
Negative Prompt
<textarea value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={3} />
</label>
<div className="history-box">
{history.slice(0, 6).map((item, idx) => (
<button key={idx} className="history-item" onClick={() => setPrompt(item.prompt)}>
{item.prompt}
</button>
))}
</div>
<label>
Batch Queue (ein Prompt pro Zeile)
<textarea value={batchPrompts} onChange={(e) => setBatchPrompts(e.target.value)} rows={4} placeholder="prompt 1&#10;prompt 2" />
</label>
<div className="actions">
<button onClick={handleGenerate} disabled={isGenerating}>Generate</button>
<button onClick={handleBatchGenerate}>Queue Batch</button>
<button onClick={handleCancel} disabled={!isGenerating}>Cancel</button>
<button onClick={applyHyperrealPortraitPreset}>Hyperreal Portrait</button>
<button onClick={applyHyperrealProductPreset}>Hyperreal Product</button>
<button onClick={savePreset}>Save Preset</button>
<button onClick={handleSaveImage} disabled={!selectedImage}>Save As</button>
<button onClick={handleOpenFolder} disabled={!jobInfo?.output_dir}>Open Folder</button>
<button onClick={() => void handleExportImage("webp")} disabled={!selectedImage}>Export WEBP</button>
</div>
</>
)}
{activeTab === "dashboard" && (
<>
<h1>Operations Dashboard</h1>
{adminSettings && (
<div className="preset-list" style={{ marginBottom: 12 }}>
<label className="row" style={{ justifyContent: "space-between" }}>
<span>Adult Repo (HF Space) aktivieren</span>
<input
type="checkbox"
checked={adminSettings.adult_enabled}
onChange={(e) =>
setAdminSettings({ ...adminSettings, adult_enabled: e.target.checked })
}
/>
</label>
<div className="row-actions">
<button onClick={() => void saveAdminSettings()}>Schalter speichern</button>
</div>
</div>
)}
<div className="stats-grid">
<div className="stat-card"><strong>Total</strong><span>{stats?.total ?? 0}</span></div>
<div className="stat-card"><strong>Queued</strong><span>{stats?.queued ?? 0}</span></div>
<div className="stat-card"><strong>Running</strong><span>{stats?.running ?? 0}</span></div>
<div className="stat-card"><strong>Done</strong><span>{stats?.done ?? 0}</span></div>
<div className="stat-card"><strong>Error</strong><span>{stats?.error ?? 0}</span></div>
<div className="stat-card"><strong>24h</strong><span>{stats?.last_24h ?? 0}</span></div>
</div>
<div className="job-table">
{jobs.slice(0, 30).map((j) => (
<div className="job-row" key={j.job_id}>
<div>{j.job_id}</div>
<div>{j.status}</div>
<div>{j.progress}%</div>
<div>{j.message}</div>
<div className="row-actions">
<button onClick={() => void handleRetry(j.job_id)}>Retry</button>
<button onClick={() => void handleCancelById(j.job_id)}>Cancel</button>
</div>
</div>
))}
</div>
</>
)}
{activeTab === "presets" && (
<>
<h1>Preset Library</h1>
<div className="preset-list">
{presets.map((preset) => (
<div className="preset-item" key={preset.name}>
<div>
<strong>{preset.name}</strong>
<div>{preset.model} | {preset.size} | steps {preset.steps}</div>
</div>
<div className="row-actions">
<button onClick={() => void applyPreset(preset)}>Apply</button>
<button onClick={() => void deletePreset(preset.name)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
{activeTab === "settings" && (
<>
<h1>Admin Settings</h1>
{adminSettings ? (
<div className="preset-list">
<label>
Content Profile
<select
value={adminSettings.content_profile}
onChange={(e) => setAdminSettings({ ...adminSettings, content_profile: e.target.value })}
>
<option value="strict">strict</option>
<option value="internal-relaxed">internal-relaxed</option>
</select>
</label>
<label>
Rate Limit / min
<input
type="number"
value={adminSettings.rate_limit_per_minute}
onChange={(e) =>
setAdminSettings({ ...adminSettings, rate_limit_per_minute: Number(e.target.value) || 1 })
}
/>
</label>
<label>
Output Retention Days
<input
type="number"
value={adminSettings.output_retention_days}
onChange={(e) =>
setAdminSettings({ ...adminSettings, output_retention_days: Number(e.target.value) || 1 })
}
/>
</label>
<label className="row" style={{ justifyContent: "space-between" }}>
<span>Adult Repo (HF Space) aktiv</span>
<input
type="checkbox"
checked={adminSettings.adult_enabled}
onChange={(e) => setAdminSettings({ ...adminSettings, adult_enabled: e.target.checked })}
/>
</label>
<div className="row-actions">
<button onClick={() => void saveAdminSettings()}>Save Settings</button>
<button onClick={() => void api("/admin/cleanup", { method: "POST" }).then(refreshStats)}>Run Cleanup</button>
</div>
</div>
) : (
<div>No admin permissions for settings.</div>
)}
</>
)}
<section className="progress-box">
<div className="progress-label">
<span>{jobInfo?.status ?? "idle"}</span>
<span>{jobInfo?.progress ?? 0}%</span>
</div>
<progress max={100} value={jobInfo?.progress ?? 0}></progress>
<div className="log-box">
{logs.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</section>
</main>
<section className="gallery">
<h2>Gallery & Compare</h2>
<div className="thumbs">
{(jobInfo?.image_paths || []).map((path) => (
<div key={path}>
<button className="thumb" onClick={() => setSelectedImage(path)}>
<ResolvedImage path={path} alt="result" />
</button>
<label className="row"><input type="checkbox" checked={compareImages.includes(path)} onChange={() => toggleCompare(path)} /> Compare</label>
</div>
))}
</div>
<div className="preview">
{selectedImage ? <ResolvedImage path={selectedImage} alt="preview" /> : <div>No preview</div>}
</div>
{compareImages.length > 0 && (
<div className="compare-grid">
{compareImages.map((path) => (
<ResolvedImage key={path} path={path} alt="compare" />
))}
</div>
)}
</section>
</div>
);
}
export default App;