page.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. "use client";
  2. import { useState, useEffect, useRef } from "react";
  3. import { Header } from "@/components/layout";
  4. import { AppLayout } from "@/components/layout";
  5. import { Button } from "@/components/ui/button";
  6. import { Input } from "@/components/ui/input";
  7. import { PromptTextarea } from "@/components/forms";
  8. import { Label } from "@/components/ui/label";
  9. import { Card, CardContent } from "@/components/ui/card";
  10. import {
  11. apiClient,
  12. type GenerationRequest,
  13. type JobInfo,
  14. type JobDetailsResponse,
  15. } from "@/lib/api";
  16. import { Loader2, Download, X, Trash2, RotateCcw, Power } from "lucide-react";
  17. import { downloadImage, downloadAuthenticatedImage } from "@/lib/utils";
  18. import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
  19. const defaultFormData: GenerationRequest = {
  20. prompt: "",
  21. negative_prompt: "",
  22. width: 512,
  23. height: 512,
  24. steps: 20,
  25. cfg_scale: 7.5,
  26. seed: "",
  27. sampling_method: "euler_a",
  28. scheduler: "default",
  29. batch_count: 1,
  30. };
  31. function Text2ImgForm() {
  32. const [formData, setFormData] = useLocalStorage<GenerationRequest>(
  33. "text2img-form-data",
  34. defaultFormData,
  35. { excludeLargeData: true, maxSize: 512 * 1024 }, // 512KB limit
  36. );
  37. const [loading, setLoading] = useState(false);
  38. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  39. const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('text2img');
  40. const [generatedImages, setGeneratedImages] = useState<string[]>(() => storedImages.map(img => img.url));
  41. const [samplers, setSamplers] = useState<
  42. Array<{ name: string; description: string }>
  43. >([]);
  44. const [schedulers, setSchedulers] = useState<
  45. Array<{ name: string; description: string }>
  46. >([]);
  47. const [loraModels, setLoraModels] = useState<string[]>([]);
  48. const [embeddings, setEmbeddings] = useState<string[]>([]);
  49. const [error, setError] = useState<string | null>(null);
  50. const pollCleanupRef = useRef<(() => void) | null>(null);
  51. // Cleanup polling on unmount
  52. useEffect(() => {
  53. return () => {
  54. if (pollCleanupRef.current) {
  55. pollCleanupRef.current();
  56. pollCleanupRef.current = null;
  57. }
  58. };
  59. }, []);
  60. useEffect(() => {
  61. const loadOptions = async () => {
  62. try {
  63. const [samplersData, schedulersData, loras, embeds] = await Promise.all(
  64. [
  65. apiClient.getSamplers(),
  66. apiClient.getSchedulers(),
  67. apiClient.getModels("lora"),
  68. apiClient.getModels("embedding"),
  69. ],
  70. );
  71. setSamplers(samplersData);
  72. setSchedulers(schedulersData);
  73. setLoraModels(loras.models.map((m) => m.name));
  74. setEmbeddings(embeds.models.map((m) => m.name));
  75. } catch (err) {
  76. console.error("Failed to load options:", err);
  77. }
  78. };
  79. loadOptions();
  80. }, []);
  81. const handleInputChange = (
  82. e: React.ChangeEvent<
  83. HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
  84. >,
  85. ) => {
  86. const { name, value } = e.target;
  87. setFormData((prev) => ({
  88. ...prev,
  89. [name]:
  90. name === "prompt" ||
  91. name === "negative_prompt" ||
  92. name === "seed" ||
  93. name === "sampling_method" ||
  94. name === "scheduler"
  95. ? value
  96. : Number(value),
  97. }));
  98. };
  99. const pollJobStatus = async (jobId: string) => {
  100. const maxAttempts = 300; // 5 minutes with 2 second interval
  101. let attempts = 0;
  102. let isPolling = true;
  103. let timeoutId: NodeJS.Timeout | null = null;
  104. const poll = async () => {
  105. if (!isPolling) return;
  106. try {
  107. const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
  108. setJobInfo(status.job);
  109. console.log(`[DEBUG] Job ${jobId} status: ${status.job.status}, progress: ${status.job.progress}, outputs:`, status.job.outputs);
  110. if (status.job.status === "completed") {
  111. let imageUrls: string[] = [];
  112. // Handle both old format (result.images) and new format (outputs)
  113. if (status.job.outputs && status.job.outputs.length > 0) {
  114. console.log(`[DEBUG] Processing ${status.job.outputs.length} outputs`);
  115. // New format: convert output URLs to authenticated image URLs with cache-busting
  116. imageUrls = status.job.outputs.map((output: { filename: string }) => {
  117. const filename = output.filename;
  118. const imageUrl = apiClient.getImageUrl(jobId, filename);
  119. console.log(`[DEBUG] Generated URL for ${filename}: ${imageUrl}`);
  120. return imageUrl;
  121. });
  122. } else if (
  123. status.job.result?.images &&
  124. status.job.result.images.length > 0
  125. ) {
  126. console.log(`[DEBUG] Using old format with ${status.job.result.images.length} images`);
  127. // Old format: convert image URLs to authenticated URLs
  128. imageUrls = status.job.result.images.map((imageUrl: string) => {
  129. // Extract filename from URL if it's a full URL
  130. if (imageUrl.includes("/output/")) {
  131. const parts = imageUrl.split("/output/");
  132. if (parts.length === 2) {
  133. const filename = parts[1].split("?")[0]; // Remove query params
  134. return apiClient.getImageUrl(jobId, filename);
  135. }
  136. }
  137. // If it's just a filename, convert it directly
  138. return apiClient.getImageUrl(jobId, imageUrl);
  139. });
  140. } else {
  141. console.log(`[DEBUG] No outputs or images found in job response`);
  142. }
  143. console.log(`[DEBUG] Final image URLs:`, imageUrls);
  144. // Create a new array to trigger React re-render
  145. setGeneratedImages([...imageUrls]);
  146. addImages(imageUrls, jobId);
  147. setLoading(false);
  148. isPolling = false;
  149. } else if (status.job.status === "failed") {
  150. console.log(`[DEBUG] Job failed with error: ${status.job.error}`);
  151. setError(status.job.error || "Generation failed");
  152. setLoading(false);
  153. isPolling = false;
  154. } else if (status.job.status === "cancelled") {
  155. console.log(`[DEBUG] Job was cancelled`);
  156. setError("Generation was cancelled");
  157. setLoading(false);
  158. isPolling = false;
  159. } else if (attempts < maxAttempts) {
  160. attempts++;
  161. timeoutId = setTimeout(poll, 2000);
  162. } else {
  163. console.log(`[DEBUG] Job polling timeout after ${attempts} attempts`);
  164. setError("Job polling timeout");
  165. setLoading(false);
  166. isPolling = false;
  167. }
  168. } catch (err) {
  169. console.log(`[DEBUG] Error polling job status:`, err);
  170. if (isPolling) {
  171. setError(
  172. err instanceof Error ? err.message : "Failed to check job status",
  173. );
  174. setLoading(false);
  175. isPolling = false;
  176. }
  177. }
  178. };
  179. poll();
  180. // Return cleanup function
  181. return () => {
  182. isPolling = false;
  183. if (timeoutId) {
  184. clearTimeout(timeoutId);
  185. }
  186. };
  187. };
  188. const handleGenerate = async (e: React.FormEvent) => {
  189. e.preventDefault();
  190. setLoading(true);
  191. setError(null);
  192. setGeneratedImages([]);
  193. setJobInfo(null);
  194. try {
  195. const requestData = {
  196. ...formData,
  197. };
  198. const job = await apiClient.text2img(requestData);
  199. setJobInfo(job);
  200. const jobId = job.request_id || job.id;
  201. if (jobId) {
  202. pollJobStatus(jobId).then((cleanup) => {
  203. pollCleanupRef.current = cleanup;
  204. });
  205. } else {
  206. setError("No job ID returned from server");
  207. setLoading(false);
  208. }
  209. } catch (err) {
  210. setError(err instanceof Error ? err.message : "Failed to generate image");
  211. setLoading(false);
  212. }
  213. };
  214. const handleCancel = async () => {
  215. const jobId = jobInfo?.request_id || jobInfo?.id;
  216. if (jobId) {
  217. try {
  218. await apiClient.cancelJob(jobId);
  219. setLoading(false);
  220. setError("Generation cancelled");
  221. // Cleanup polling
  222. if (pollCleanupRef.current) {
  223. pollCleanupRef.current();
  224. pollCleanupRef.current = null;
  225. }
  226. } catch (err) {
  227. console.error("Failed to cancel job:", err);
  228. }
  229. }
  230. };
  231. const handleClearPrompts = () => {
  232. setFormData({ ...formData, prompt: "", negative_prompt: "" });
  233. };
  234. const handleResetToDefaults = () => {
  235. setFormData(defaultFormData);
  236. };
  237. const handleServerRestart = async () => {
  238. if (
  239. !confirm(
  240. "Are you sure you want to restart the server? This will cancel all running jobs.",
  241. )
  242. ) {
  243. return;
  244. }
  245. try {
  246. setLoading(true);
  247. await apiClient.restartServer();
  248. setError("Server restart initiated. Please wait...");
  249. setTimeout(() => {
  250. window.location.reload();
  251. }, 3000);
  252. } catch (err) {
  253. setError(err instanceof Error ? err.message : "Failed to restart server");
  254. setLoading(false);
  255. }
  256. };
  257. return (
  258. <AppLayout>
  259. <Header
  260. title="Text to Image"
  261. description="Generate images from text prompts"
  262. />
  263. <div className="container mx-auto p-6">
  264. <div className="grid gap-6 lg:grid-cols-2">
  265. {/* Left Panel - Form */}
  266. <Card>
  267. <CardContent className="pt-6">
  268. <form onSubmit={handleGenerate} className="space-y-4">
  269. <div className="space-y-2">
  270. <Label htmlFor="prompt">Prompt *</Label>
  271. <PromptTextarea
  272. value={formData.prompt}
  273. onChange={(value) =>
  274. setFormData({ ...formData, prompt: value })
  275. }
  276. placeholder="a beautiful landscape with mountains and a lake, sunset, highly detailed..."
  277. rows={4}
  278. loras={loraModels}
  279. embeddings={embeddings}
  280. />
  281. <p className="text-xs text-muted-foreground">
  282. Tip: Use &lt;lora:name:weight&gt; for LoRAs (e.g.,
  283. &lt;lora:myLora:0.8&gt;) and embedding names directly
  284. </p>
  285. </div>
  286. <div className="space-y-2">
  287. <Label htmlFor="negative_prompt">Negative Prompt</Label>
  288. <PromptTextarea
  289. value={formData.negative_prompt || ""}
  290. onChange={(value) =>
  291. setFormData({ ...formData, negative_prompt: value })
  292. }
  293. placeholder="blurry, low quality, distorted..."
  294. rows={2}
  295. loras={loraModels}
  296. embeddings={embeddings}
  297. />
  298. </div>
  299. {/* Utility Buttons */}
  300. <div className="flex gap-2">
  301. <Button
  302. type="button"
  303. variant="outline"
  304. size="sm"
  305. onClick={handleClearPrompts}
  306. disabled={loading}
  307. title="Clear both prompts"
  308. >
  309. <Trash2 className="h-4 w-4 mr-1" />
  310. Clear Prompts
  311. </Button>
  312. <Button
  313. type="button"
  314. variant="outline"
  315. size="sm"
  316. onClick={handleResetToDefaults}
  317. disabled={loading}
  318. title="Reset all fields to defaults"
  319. >
  320. <RotateCcw className="h-4 w-4 mr-1" />
  321. Reset to Defaults
  322. </Button>
  323. <Button
  324. type="button"
  325. variant="outline"
  326. size="sm"
  327. onClick={handleServerRestart}
  328. disabled={loading}
  329. title="Restart the backend server"
  330. >
  331. <Power className="h-4 w-4 mr-1" />
  332. Restart Server
  333. </Button>
  334. </div>
  335. <div className="grid grid-cols-2 gap-4">
  336. <div className="space-y-2">
  337. <Label htmlFor="width">Width</Label>
  338. <Input
  339. id="width"
  340. name="width"
  341. type="number"
  342. value={formData.width}
  343. onChange={handleInputChange}
  344. step={64}
  345. min={256}
  346. max={2048}
  347. />
  348. </div>
  349. <div className="space-y-2">
  350. <Label htmlFor="height">Height</Label>
  351. <Input
  352. id="height"
  353. name="height"
  354. type="number"
  355. value={formData.height}
  356. onChange={handleInputChange}
  357. step={64}
  358. min={256}
  359. max={2048}
  360. />
  361. </div>
  362. </div>
  363. <div className="grid grid-cols-2 gap-4">
  364. <div className="space-y-2">
  365. <Label htmlFor="steps">Steps</Label>
  366. <Input
  367. id="steps"
  368. name="steps"
  369. type="number"
  370. value={formData.steps}
  371. onChange={handleInputChange}
  372. min={1}
  373. max={150}
  374. />
  375. </div>
  376. <div className="space-y-2">
  377. <Label htmlFor="cfg_scale">CFG Scale</Label>
  378. <Input
  379. id="cfg_scale"
  380. name="cfg_scale"
  381. type="number"
  382. value={formData.cfg_scale}
  383. onChange={handleInputChange}
  384. step={0.5}
  385. min={1}
  386. max={30}
  387. />
  388. </div>
  389. </div>
  390. <div className="space-y-2">
  391. <Label htmlFor="seed">Seed (optional)</Label>
  392. <Input
  393. id="seed"
  394. name="seed"
  395. value={formData.seed}
  396. onChange={handleInputChange}
  397. placeholder="Leave empty for random"
  398. />
  399. </div>
  400. <div className="space-y-2">
  401. <Label htmlFor="sampling_method">Sampling Method</Label>
  402. <select
  403. id="sampling_method"
  404. name="sampling_method"
  405. value={formData.sampling_method}
  406. onChange={handleInputChange}
  407. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  408. >
  409. {samplers.length > 0 ? (
  410. samplers.map((sampler) => (
  411. <option key={sampler.name} value={sampler.name}>
  412. {sampler.name.toUpperCase()} - {sampler.description}
  413. </option>
  414. ))
  415. ) : (
  416. <option value="euler_a">Loading...</option>
  417. )}
  418. </select>
  419. </div>
  420. <div className="space-y-2">
  421. <Label htmlFor="scheduler">Scheduler</Label>
  422. <select
  423. id="scheduler"
  424. name="scheduler"
  425. value={formData.scheduler}
  426. onChange={handleInputChange}
  427. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  428. >
  429. {schedulers.length > 0 ? (
  430. schedulers.map((scheduler) => (
  431. <option key={scheduler.name} value={scheduler.name}>
  432. {scheduler.name.toUpperCase()} -{" "}
  433. {scheduler.description}
  434. </option>
  435. ))
  436. ) : (
  437. <option value="default">Loading...</option>
  438. )}
  439. </select>
  440. </div>
  441. <div className="space-y-2">
  442. <Label htmlFor="batch_count">Batch Count</Label>
  443. <Input
  444. id="batch_count"
  445. name="batch_count"
  446. type="number"
  447. value={formData.batch_count}
  448. onChange={handleInputChange}
  449. min={1}
  450. max={4}
  451. />
  452. </div>
  453. <div className="flex gap-2">
  454. <Button type="submit" disabled={loading} className="flex-1">
  455. {loading ? (
  456. <>
  457. <Loader2 className="h-4 w-4 animate-spin" />
  458. Generating...
  459. </>
  460. ) : (
  461. "Generate"
  462. )}
  463. </Button>
  464. {loading && (
  465. <Button
  466. type="button"
  467. variant="destructive"
  468. onClick={handleCancel}
  469. >
  470. <X className="h-4 w-4" />
  471. Cancel
  472. </Button>
  473. )}
  474. </div>
  475. {error && (
  476. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  477. {error}
  478. </div>
  479. )}
  480. </form>
  481. </CardContent>
  482. </Card>
  483. {/* Right Panel - Generated Images */}
  484. <Card>
  485. <CardContent className="pt-6">
  486. <div className="space-y-4">
  487. <h3 className="text-lg font-semibold">Generated Images</h3>
  488. {generatedImages.length === 0 ? (
  489. <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
  490. <p className="text-muted-foreground">
  491. {loading
  492. ? "Generating..."
  493. : "Generated images will appear here"}
  494. </p>
  495. </div>
  496. ) : (
  497. <div className="grid gap-4">
  498. {generatedImages.map((image, index) => (
  499. <div key={index} className="relative group">
  500. <img
  501. src={image}
  502. alt={`Generated ${index + 1}`}
  503. className="w-full rounded-lg border border-border"
  504. />
  505. <Button
  506. size="icon"
  507. variant="secondary"
  508. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  509. onClick={() => {
  510. const authToken =
  511. localStorage.getItem("auth_token");
  512. const unixUser = localStorage.getItem("unix_user");
  513. downloadAuthenticatedImage(
  514. image,
  515. `generated-${Date.now()}-${index}.png`,
  516. authToken || undefined,
  517. unixUser || undefined,
  518. ).catch((err) => {
  519. console.error("Failed to download image:", err);
  520. // Fallback to regular download if authenticated download fails
  521. downloadImage(
  522. image,
  523. `generated-${Date.now()}-${index}.png`,
  524. );
  525. });
  526. }}
  527. >
  528. <Download className="h-4 w-4" />
  529. </Button>
  530. </div>
  531. ))}
  532. </div>
  533. )}
  534. </div>
  535. </CardContent>
  536. </Card>
  537. </div>
  538. </div>
  539. </AppLayout>
  540. );
  541. }
  542. export default function Text2ImgPage() {
  543. return <Text2ImgForm />;
  544. }