page.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  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 { Progress } from "@/components/ui/progress";
  8. import { PromptTextarea } from "@/components/forms";
  9. import { Label } from "@/components/ui/label";
  10. import { Card, CardContent } from "@/components/ui/card";
  11. import {
  12. Select,
  13. SelectContent,
  14. SelectItem,
  15. SelectTrigger,
  16. SelectValue,
  17. } from "@/components/ui/select";
  18. import {
  19. apiClient,
  20. type GenerationRequest,
  21. type JobInfo,
  22. type JobDetailsResponse,
  23. } from "@/lib/api";
  24. import { Loader2, Download, X, Trash2, RotateCcw, Power } from "lucide-react";
  25. import { downloadImage, downloadAuthenticatedImage } from "@/lib/utils";
  26. import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
  27. import { useModelTypeSelection } from "@/contexts/model-selection-context";
  28. const defaultFormData: GenerationRequest = {
  29. prompt: "",
  30. negative_prompt: "",
  31. width: 512,
  32. height: 512,
  33. steps: 20,
  34. cfg_scale: 7.5,
  35. seed: "",
  36. sampling_method: "euler_a",
  37. scheduler: "default",
  38. batch_count: 1,
  39. };
  40. function Text2ImgForm() {
  41. const {
  42. availableModels: vaeModels,
  43. selectedModel: selectedVae,
  44. setSelectedModel: setSelectedVae,
  45. } = useModelTypeSelection("vae");
  46. const {
  47. availableModels: taesdModels,
  48. selectedModel: selectedTaesd,
  49. setSelectedModel: setSelectedTaesd,
  50. } = useModelTypeSelection("taesd");
  51. const [formData, setFormData] = useLocalStorage<GenerationRequest>(
  52. "text2img-form-data",
  53. defaultFormData,
  54. { excludeLargeData: true, maxSize: 512 * 1024 }, // 512KB limit
  55. );
  56. const [loading, setLoading] = useState(false);
  57. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  58. const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('text2img');
  59. const [generatedImages, setGeneratedImages] = useState<string[]>(() => storedImages.map(img => img.url));
  60. const [samplers, setSamplers] = useState<
  61. Array<{ name: string; description: string }>
  62. >([]);
  63. const [schedulers, setSchedulers] = useState<
  64. Array<{ name: string; description: string }>
  65. >([]);
  66. const [loraModels, setLoraModels] = useState<string[]>([]);
  67. const [embeddings, setEmbeddings] = useState<string[]>([]);
  68. const [error, setError] = useState<string | null>(null);
  69. const pollCleanupRef = useRef<(() => void) | null>(null);
  70. // Cleanup polling on unmount
  71. useEffect(() => {
  72. return () => {
  73. if (pollCleanupRef.current) {
  74. pollCleanupRef.current();
  75. pollCleanupRef.current = null;
  76. }
  77. };
  78. }, []);
  79. useEffect(() => {
  80. const loadOptions = async () => {
  81. try {
  82. const [samplersData, schedulersData, loras, embeds] = await Promise.all(
  83. [
  84. apiClient.getSamplers(),
  85. apiClient.getSchedulers(),
  86. apiClient.getModels("lora"),
  87. apiClient.getModels("embedding"),
  88. ],
  89. );
  90. setSamplers(samplersData);
  91. setSchedulers(schedulersData);
  92. setLoraModels(loras.models.map((m) => m.name));
  93. setEmbeddings(embeds.models.map((m) => m.name));
  94. } catch (err) {
  95. console.error("Failed to load options:", err);
  96. }
  97. };
  98. loadOptions();
  99. }, []);
  100. const handleInputChange = (
  101. e: React.ChangeEvent<
  102. HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
  103. >,
  104. ) => {
  105. const { name, value } = e.target;
  106. setFormData((prev) => ({
  107. ...prev,
  108. [name]:
  109. name === "prompt" ||
  110. name === "negative_prompt" ||
  111. name === "seed" ||
  112. name === "sampling_method" ||
  113. name === "scheduler"
  114. ? value
  115. : Number(value),
  116. }));
  117. };
  118. const pollJobStatus = async (jobId: string) => {
  119. const maxAttempts = 300; // 5 minutes with 2 second interval
  120. let attempts = 0;
  121. let isPolling = true;
  122. let timeoutId: NodeJS.Timeout | null = null;
  123. const poll = async () => {
  124. if (!isPolling) return;
  125. try {
  126. const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
  127. setJobInfo(status.job);
  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. const modelId = model.sha256_short || model.sha256 || model.id || model.name;
  474. const displayName = model.sha256_short
  475. ? `${model.name} (${model.sha256_short})`
  476. : model.name;
  477. return (
  478. <SelectItem key={modelId} value={modelId}>
  479. {displayName}
  480. </SelectItem>
  481. );
  482. })}
  483. </SelectContent>
  484. </Select>
  485. </div>
  486. <div className="space-y-2">
  487. <Label>TAESD Model (Optional)</Label>
  488. <Select
  489. value={selectedTaesd || "none"}
  490. onValueChange={(value) => setSelectedTaesd(value === "none" ? undefined : value)}
  491. >
  492. <SelectTrigger>
  493. <SelectValue placeholder="Select TAESD model" />
  494. </SelectTrigger>
  495. <SelectContent>
  496. <SelectItem value="none">None</SelectItem>
  497. {taesdModels.map((model) => {
  498. const modelId = model.sha256_short || model.sha256 || model.id || model.name;
  499. const displayName = model.sha256_short
  500. ? `${model.name} (${model.sha256_short})`
  501. : model.name;
  502. return (
  503. <SelectItem key={modelId} value={modelId}>
  504. {displayName}
  505. </SelectItem>
  506. );
  507. })}
  508. </SelectContent>
  509. </Select>
  510. </div>
  511. <div className="space-y-2">
  512. <Label htmlFor="batch_count">Batch Count</Label>
  513. <Input
  514. id="batch_count"
  515. name="batch_count"
  516. type="number"
  517. value={formData.batch_count}
  518. onChange={handleInputChange}
  519. min={1}
  520. max={4}
  521. />
  522. </div>
  523. <div className="flex gap-2">
  524. <Button type="submit" disabled={loading} className="flex-1">
  525. {loading ? (
  526. <>
  527. <Loader2 className="h-4 w-4 animate-spin" />
  528. Generating...
  529. </>
  530. ) : (
  531. "Generate"
  532. )}
  533. </Button>
  534. {loading && (
  535. <Button
  536. type="button"
  537. variant="destructive"
  538. onClick={handleCancel}
  539. >
  540. <X className="h-4 w-4" />
  541. Cancel
  542. </Button>
  543. )}
  544. </div>
  545. {error && (
  546. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  547. {error}
  548. </div>
  549. )}
  550. </form>
  551. </CardContent>
  552. </Card>
  553. {/* Right Panel - Generated Images */}
  554. <Card>
  555. <CardContent className="pt-6">
  556. <div className="space-y-4">
  557. <h3 className="text-lg font-semibold">Generated Images</h3>
  558. {/* Progress Display */}
  559. {loading && jobInfo && (
  560. <div className="space-y-2">
  561. <div className="flex justify-between text-sm">
  562. <span>Progress</span>
  563. <span>{Math.round(jobInfo.overall_progress || jobInfo.progress || 0)}%</span>
  564. </div>
  565. <Progress value={jobInfo.overall_progress || jobInfo.progress || 0} className="w-full" />
  566. {jobInfo.model_load_progress !== undefined && jobInfo.generation_progress !== undefined && (
  567. <div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
  568. <div>Model Loading: {Math.round(jobInfo.model_load_progress)}%</div>
  569. <div>Generation: {Math.round(jobInfo.generation_progress)}%</div>
  570. </div>
  571. )}
  572. </div>
  573. )}
  574. {generatedImages.length === 0 ? (
  575. <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
  576. <p className="text-muted-foreground">
  577. {loading
  578. ? "Generating..."
  579. : "Generated images will appear here"}
  580. </p>
  581. </div>
  582. ) : (
  583. <div className="grid gap-4">
  584. {generatedImages.map((image, index) => (
  585. <div key={index} className="relative group">
  586. <img
  587. src={image}
  588. alt={`Generated ${index + 1}`}
  589. className="w-full rounded-lg border border-border"
  590. />
  591. <Button
  592. size="icon"
  593. variant="secondary"
  594. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  595. onClick={() => {
  596. const authToken =
  597. localStorage.getItem("auth_token");
  598. const unixUser = localStorage.getItem("unix_user");
  599. downloadAuthenticatedImage(
  600. image,
  601. `generated-${Date.now()}-${index}.png`,
  602. authToken || undefined,
  603. unixUser || undefined,
  604. ).catch((err) => {
  605. console.error("Failed to download image:", err);
  606. // Fallback to regular download if authenticated download fails
  607. downloadImage(
  608. image,
  609. `generated-${Date.now()}-${index}.png`,
  610. );
  611. });
  612. }}
  613. >
  614. <Download className="h-4 w-4" />
  615. </Button>
  616. </div>
  617. ))}
  618. </div>
  619. )}
  620. </div>
  621. </CardContent>
  622. </Card>
  623. </div>
  624. </div>
  625. </AppLayout>
  626. );
  627. }
  628. export default function Text2ImgPage() {
  629. return <Text2ImgForm />;
  630. }