page.tsx 19 KB

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