page.tsx 22 KB

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