返回 oh-my-ppt
ImageGenerationPanel.tsx
根目录 / src / renderer / src / components / session-detail / ai-panel / ImageGenerationPanel.tsx
1 import {
2 CircleAlert,
3 FolderOpen,
4 Image as ImageIcon,
5 ImagePlus,
6 Loader2,
7 Maximize2,
8 SlidersHorizontal,
9 Sparkles,
10 StopCircle,
11 Wallpaper,
12 RefreshCw
13 } from 'lucide-react'
14 import dayjs from 'dayjs'
15 import { useEffect, useMemo, useRef, useState } from 'react'
16 import type { GeneratedImageAsset } from '@shared/image-generation.js'
17 import { localAssetUrl } from '@shared/local-asset'
18 import { useT } from '@renderer/i18n'
19 import { ipc, type ImageFulfillmentJobView } from '@renderer/lib/ipc'
20 import { cn } from '@renderer/lib/utils'
21 import { useSessionDetailUiStore, useSettingsStore, useToastStore } from '@renderer/store'
22 import { useModelAction } from '@renderer/hooks/useModelAction'
23 import { ModelSplitButton } from '@renderer/components/model/ModelActionButton'
24 import { Button } from '../../ui/Button'
25 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/Dialog'
26 import { Popover, PopoverContent, PopoverTrigger } from '../../ui/Popover'
27 import { Progress } from '../../ui/Progress'
28 import { ScrollArea } from '../../ui/ScrollArea'
29 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/Select'
30 import { Textarea } from '../../ui/Input'
31 import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip'
32 import { resolveImageSizeOptions } from './imageSizeOptions'
33 import { useImageGenerationActions } from '../hooks/useImageGenerationActions'
34
35 const localAssetSrc = (absolutePath?: string): string =>
36 absolutePath ? localAssetUrl(absolutePath) : ''
37
38 export function ImageGenerationPanel({ sessionId }: { sessionId: string }): React.JSX.Element {
39 const t = useT()
40 const modelAction = useModelAction()
41 const { selectedPage, generate, cancel, addToCanvas, setAsBackground, revealFile } =
42 useImageGenerationActions(sessionId)
43 const selectedPageExists = Boolean(selectedPage?.pageId)
44 const selectedPageHtmlPath = selectedPage?.htmlPath
45 const selectedPageNumber = selectedPage?.pageNumber
46 const selectedPageTitle = selectedPage?.title
47 const selectedPageOutline = selectedPage?.contentOutline
48 const imageModelConfigs = useSettingsStore((state) => state.imageModelConfigs)
49 const { error: toastError, success: toastSuccess } = useToastStore()
50 const imagePrompt = useSessionDetailUiStore((state) => state.imagePrompt)
51 const imageMessages = useSessionDetailUiStore((state) => state.imageMessages)
52 const selectedImageModelConfigId = useSessionDetailUiStore(
53 (state) => state.selectedImageModelConfigId
54 )
55 const imageSize = useSessionDetailUiStore((state) => state.imageSize)
56 const imageCount = 1
57 const isGeneratingImage = useSessionDetailUiStore((state) => state.isGeneratingImage)
58 const imageProgress = useSessionDetailUiStore((state) => state.imageProgress)
59 const setImagePrompt = useSessionDetailUiStore((state) => state.setImagePrompt)
60 const setSelectedImageModelConfigId = useSessionDetailUiStore(
61 (state) => state.setSelectedImageModelConfigId
62 )
63 const setImageSize = useSessionDetailUiStore((state) => state.setImageSize)
64 const [previewAsset, setPreviewAsset] = useState<GeneratedImageAsset | null>(null)
65 const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false)
66 const [fulfillmentJobs, setFulfillmentJobs] = useState<ImageFulfillmentJobView[]>([])
67 const [retryingFulfillmentJobId, setRetryingFulfillmentJobId] = useState<string | null>(null)
68 const messagesEndRef = useRef<HTMLDivElement>(null)
69 const hasImageModels = imageModelConfigs.length > 0
70 const imageControlsDisabled = !hasImageModels || isGeneratingImage || isGeneratingPrompt
71 const selectedImageModel =
72 imageModelConfigs.find((config) => config.id === selectedImageModelConfigId) ||
73 imageModelConfigs.find((config) => config.active) ||
74 imageModelConfigs[0]
75 const imageSizeOptions = useMemo(
76 () => resolveImageSizeOptions(selectedImageModel),
77 [selectedImageModel]
78 )
79 const selectedImageSizeOption =
80 imageSizeOptions.find((option) => option.value === imageSize) || imageSizeOptions[0]
81
82 useEffect(() => {
83 const selectedExists = imageModelConfigs.some(
84 (config) => config.id === selectedImageModelConfigId
85 )
86 if (selectedImageModelConfigId && selectedExists) return
87 const active = imageModelConfigs.find((config) => config.active) || imageModelConfigs[0]
88 if (active) {
89 setSelectedImageModelConfigId(active.id)
90 }
91 }, [imageModelConfigs, selectedImageModelConfigId, setSelectedImageModelConfigId])
92
93 useEffect(() => {
94 if (imageSizeOptions.length === 0) return
95 if (imageSizeOptions.some((option) => option.value === imageSize)) return
96 setImageSize(imageSizeOptions[0].value)
97 }, [imageSize, imageSizeOptions, setImageSize])
98
99 useEffect(() => {
100 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
101 }, [imageMessages, isGeneratingImage, imageProgress?.progress])
102
103 useEffect(() => {
104 if (!sessionId || !selectedPage?.pageId) {
105 setFulfillmentJobs([])
106 return
107 }
108 let disposed = false
109 const loadJobs = async (): Promise<void> => {
110 try {
111 const jobs = await ipc.listImageFulfillmentJobs({
112 sessionId,
113 pageId: selectedPage.pageId
114 })
115 if (!disposed) setFulfillmentJobs(jobs)
116 } catch {
117 if (!disposed) setFulfillmentJobs([])
118 }
119 }
120 void loadJobs()
121 const timer = window.setInterval(() => void loadJobs(), 5000)
122 return () => {
123 disposed = true
124 window.clearInterval(timer)
125 }
126 }, [selectedPage?.pageId, sessionId])
127
128 const handleFillFromOutline = (): void => {
129 if (!hasImageModels) return
130 const parts = [
131 t('sessionDetail.imageOutlinePromptIntro'),
132 selectedPageTitle ? `${t('sessionDetail.imageOutlineTitle')}: ${selectedPageTitle}` : '',
133 selectedPageOutline
134 ? `${t('sessionDetail.imageOutlineContent')}: ${selectedPageOutline}`
135 : '',
136 t('sessionDetail.imageOutlineConstraints')
137 ].filter(Boolean)
138 setImagePrompt(parts.join('\n'))
139 }
140
141 const handleGeneratePromptFromCurrentPage = async (modelConfigId: string): Promise<void> => {
142 if (
143 !hasImageModels ||
144 !selectedPageExists ||
145 !sessionId ||
146 !selectedPageHtmlPath ||
147 isGeneratingPrompt
148 ) {
149 return
150 }
151 const activeModelConfigId = await modelAction.ensureModelActive(modelConfigId)
152 if (!activeModelConfigId) return
153
154 setIsGeneratingPrompt(true)
155 try {
156 const result = await ipc.generateImagePrompt({
157 sessionId,
158 htmlPath: selectedPageHtmlPath,
159 userPrompt: imagePrompt,
160 pageTitle: selectedPageTitle,
161 pageOutline: selectedPageOutline || undefined,
162 modelConfigId: activeModelConfigId
163 })
164 setImagePrompt(result.prompt)
165 toastSuccess(t('sessionDetail.imagePromptGenerated'))
166 } catch (error) {
167 const message =
168 error instanceof Error ? error.message : t('sessionDetail.imagePromptGenerateFailed')
169 toastError(t('sessionDetail.imagePromptGenerateFailed'), { description: message })
170 } finally {
171 setIsGeneratingPrompt(false)
172 }
173 }
174
175 const generateDisabled = imageControlsDisabled || !selectedPageExists || !imagePrompt.trim()
176 const latestFulfillmentJob = fulfillmentJobs[0]
177 const optionSummary = selectedImageModel
178 ? `${selectedImageModel.name} · ${selectedImageSizeOption?.label || imageSize} · ${imageCount}`
179 : t('sessionDetail.imageOptions')
180
181 const retryFailedIllustration = async (): Promise<void> => {
182 if (!latestFulfillmentJob || retryingFulfillmentJobId) return
183 setRetryingFulfillmentJobId(latestFulfillmentJob.id)
184 try {
185 const result = await ipc.retryImageFulfillment({
186 sessionId,
187 jobId: latestFulfillmentJob.id
188 })
189 if (result.status === 'degraded' || result.status === 'cancelled') {
190 throw new Error(result.error || t('sessionDetail.autoImageRetryFailed'))
191 }
192 const jobs = await ipc.listImageFulfillmentJobs({
193 sessionId,
194 pageId: selectedPage?.pageId
195 })
196 setFulfillmentJobs(jobs)
197 toastSuccess(t('sessionDetail.autoImageRetryStarted'))
198 } catch (error) {
199 toastError(t('sessionDetail.autoImageRetryFailed'), {
200 description: error instanceof Error ? error.message : undefined
201 })
202 } finally {
203 setRetryingFulfillmentJobId(null)
204 }
205 }
206
207 return (
208 <div className="flex min-h-0 flex-1 flex-col">
209 <div className="relative mx-2.5 mt-2.5 overflow-hidden rounded-[1.35rem] border border-[#e1d6c4]/72 bg-[#fffaf1]/78 px-3 pb-2.5 pt-3 shadow-[0_4px_12px_rgba(77,61,43,0.06)]">
210 <div className="relative flex flex-col gap-2">
211 <h3 className="text-sm font-semibold tracking-[0.04em] text-[#34402c]">
212 {t('sessionDetail.imageMode')}
213 </h3>
214 <div className="flex items-center justify-between gap-2 text-xs text-[#6d604d]">
215 <span>
216 {selectedPageExists && selectedPageNumber
217 ? t('sessionDetail.pageContext', { pageNumber: selectedPageNumber })
218 : t('sessionDetail.selectPageFirst')}
219 </span>
220 <Sparkles className="h-4 w-4 text-[#6f8f64]" />
221 </div>
222 </div>
223 </div>
224
225 {latestFulfillmentJob &&
226 (latestFulfillmentJob.status === 'pending' ||
227 latestFulfillmentJob.status === 'running' ||
228 latestFulfillmentJob.status === 'finalizing' ||
229 latestFulfillmentJob.status === 'degraded' ||
230 latestFulfillmentJob.status === 'failed' ||
231 latestFulfillmentJob.status === 'cancelled') && (
232 <div className="mx-2.5 mt-2 flex items-start gap-2 rounded-lg border border-[#ded2bd]/78 bg-[#fffaf1]/84 px-2.5 py-2 text-xs text-[#5c513f]">
233 {latestFulfillmentJob.status === 'pending' ||
234 latestFulfillmentJob.status === 'running' ||
235 latestFulfillmentJob.status === 'finalizing' ? (
236 <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-[#6f8f64]" />
237 ) : (
238 <CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#b26b3c]" />
239 )}
240 <div className="min-w-0 flex-1">
241 <p className="leading-5">
242 {latestFulfillmentJob.status === 'degraded'
243 ? latestFulfillmentJob.layout_failed_count > 0
244 ? t('sessionDetail.autoImageLayoutFailed')
245 : t('sessionDetail.autoImageDegraded')
246 : latestFulfillmentJob.status === 'failed'
247 ? t('sessionDetail.autoImageRetryFailed')
248 : latestFulfillmentJob.status === 'cancelled'
249 ? t('sessionDetail.autoImageCancelled')
250 : t('sessionDetail.autoImageInProgress')}
251 </p>
252 {latestFulfillmentJob.error &&
253 latestFulfillmentJob.status !== 'pending' &&
254 latestFulfillmentJob.status !== 'running' &&
255 latestFulfillmentJob.status !== 'finalizing' && (
256 <p className="mt-0.5 line-clamp-2 text-[11px] leading-4 text-[#8a735d]">
257 {latestFulfillmentJob.error}
258 </p>
259 )}
260 </div>
261 {latestFulfillmentJob.retryable_intent_count > 0 &&
262 (latestFulfillmentJob.status === 'degraded' ||
263 latestFulfillmentJob.status === 'failed' ||
264 latestFulfillmentJob.status === 'cancelled') && (
265 <Button
266 type="button"
267 size="sm"
268 variant="ghost"
269 className="h-7 shrink-0 px-1.5 text-[11px] text-[#5d6b4d] hover:bg-[#dcebcf]/72"
270 disabled={retryingFulfillmentJobId === latestFulfillmentJob.id}
271 onClick={() => void retryFailedIllustration()}
272 >
273 {retryingFulfillmentJobId === latestFulfillmentJob.id ? (
274 <Loader2 className="h-3.5 w-3.5 animate-spin" />
275 ) : (
276 <RefreshCw className="mr-1 h-3.5 w-3.5" />
277 )}
278 {t('sessionDetail.autoImageRetry')}
279 </Button>
280 )}
281 </div>
282 )}
283
284 <ScrollArea
285 data-image-results-container
286 className="min-h-0 flex-1"
287 viewportClassName="px-2.5 py-2"
288 >
289 {imageMessages.length === 0 && !isGeneratingImage ? (
290 <div className="mt-24 flex min-h-full flex-col items-center justify-center gap-2 text-center text-sm text-[#7a6b56]">
291 <ImageIcon className="h-8 w-8 text-[#9ba88d]" />
292 <span>{t('sessionDetail.imageResultEmpty')}</span>
293 </div>
294 ) : (
295 <div className="flex min-h-full flex-col justify-end gap-2.5">
296 {imageMessages.map((message) => {
297 const isUser = message.role === 'user'
298 return (
299 <div
300 key={message.id}
301 className={cn('flex w-full min-w-0', isUser ? 'justify-end' : 'justify-start')}
302 >
303 <div
304 className={cn(
305 'min-w-0 overflow-hidden rounded-[1.15rem] border px-3 py-2 shadow-[0_6px_14px_rgba(74,59,42,0.08)]',
306 isUser ? 'w-fit max-w-[238px]' : 'w-full max-w-[238px]',
307 isUser
308 ? 'border-[#d6e3c8]/78 bg-[#fbfef6]/90 text-[#34402c]'
309 : 'border-[#ded2bd]/78 bg-[#fffaf1]/88 text-[#3f372b]'
310 )}
311 >
312 {message.content && (
313 <p className="whitespace-pre-wrap break-words text-[13px] leading-5">
314 {message.content}
315 </p>
316 )}
317 {message.assets && message.assets.length > 0 && (
318 <div className={cn('flex flex-col gap-2', message.content && 'mt-2')}>
319 {message.assets.map((asset) => (
320 <div
321 key={asset.id}
322 className="rounded-[0.9rem] border border-[#ded2bd]/72 bg-[#fffdf8]/82 p-1.5"
323 >
324 {asset.absolutePath ? (
325 <button
326 type="button"
327 className="group relative flex h-[132px] w-full items-center justify-center overflow-hidden rounded-[0.7rem] bg-[#f6efe3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8aa878]"
328 onClick={() => setPreviewAsset(asset)}
329 aria-label={t('sessionDetail.imagePreviewOpen')}
330 title={t('sessionDetail.imagePreviewOpen')}
331 >
332 <img
333 src={localAssetSrc(asset.absolutePath)}
334 alt={asset.fileName}
335 className="h-full w-full object-contain"
336 />
337 <span className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-[#2f3829]/70 text-[#fffaf1] opacity-0 shadow-[0_6px_14px_rgba(33,29,21,0.24)] transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
338 <Maximize2 className="h-3 w-3" />
339 </span>
340 </button>
341 ) : (
342 <div className="flex h-[132px] w-full items-center justify-center rounded-[0.7rem] border border-dashed border-[#ded2bd]/72 text-[11px] text-muted-foreground">
343 {asset.fileName}
344 </div>
345 )}
346 {(() => {
347 const modelConfig = imageModelConfigs.find(
348 (c) => c.id === asset.modelConfigId
349 )
350 const modelLabel = modelConfig?.name || asset.model
351 return (
352 <p className="mt-1 text-center text-[10px] leading-3 text-muted-foreground">
353 {modelLabel}
354 </p>
355 )
356 })()}
357 <div className="mt-1 flex items-center justify-center gap-1">
358 {asset.absolutePath && (
359 <Tooltip>
360 <TooltipTrigger asChild>
361 <Button
362 type="button"
363 variant="ghost"
364 size="sm"
365 className="h-7 w-7 shrink-0 rounded-[7px] p-0 text-[#5d6b4d] hover:bg-[#dcebcf]/72 disabled:opacity-40"
366 disabled={!selectedPageExists}
367 onClick={() => void addToCanvas(asset)}
368 aria-label={t('sessionDetail.addImageToCanvas')}
369 >
370 <ImagePlus className="h-3.5 w-3.5" />
371 </Button>
372 </TooltipTrigger>
373 <TooltipContent>
374 {t('sessionDetail.addImageToCanvas')}
375 </TooltipContent>
376 </Tooltip>
377 )}
378 {asset.absolutePath && (
379 <Tooltip>
380 <TooltipTrigger asChild>
381 <Button
382 type="button"
383 variant="ghost"
384 size="sm"
385 className="h-7 w-7 shrink-0 rounded-[7px] p-0 text-[#5d6b4d] hover:bg-[#dcebcf]/72 disabled:opacity-40"
386 disabled={!selectedPageExists}
387 onClick={() => void setAsBackground(asset)}
388 aria-label={t('sessionDetail.setImageAsBackground')}
389 >
390 <Wallpaper className="h-3.5 w-3.5" />
391 </Button>
392 </TooltipTrigger>
393 <TooltipContent>
394 {t('sessionDetail.setImageAsBackground')}
395 </TooltipContent>
396 </Tooltip>
397 )}
398 {asset.absolutePath && (
399 <Tooltip>
400 <TooltipTrigger asChild>
401 <Button
402 size="sm"
403 variant="ghost"
404 className="h-7 w-7 rounded-[7px] p-0 text-[#5d6b4d] hover:bg-[#f4ebdc]"
405 onClick={() => void revealFile(asset.absolutePath)}
406 aria-label={t('sessionDetail.revealFile')}
407 >
408 <FolderOpen className="h-3.5 w-3.5" />
409 </Button>
410 </TooltipTrigger>
411 <TooltipContent>{t('sessionDetail.revealFile')}</TooltipContent>
412 </Tooltip>
413 )}
414 </div>
415 </div>
416 ))}
417 </div>
418 )}
419 <p className="mt-1 text-[11px] leading-4 text-muted-foreground">
420 {dayjs(message.createdAt * 1000).format('YYYY-MM-DD HH:mm:ss')}
421 </p>
422 </div>
423 </div>
424 )
425 })}
426 {isGeneratingImage && imageProgress && (
427 <div className="w-full max-w-[238px] rounded-[1.15rem] border border-[#ded2bd]/72 bg-[#fffaf1]/82 px-3 py-2 shadow-[0_6px_14px_rgba(74,59,42,0.08)]">
428 <p className="mb-2 text-sm text-[#655843]">
429 {imageProgress.label || t('sessionDetail.imageGenerating')}
430 </p>
431 <Progress value={imageProgress.progress} />
432 </div>
433 )}
434 <div ref={messagesEndRef} />
435 </div>
436 )}
437 </ScrollArea>
438
439 <div className="mx-2.5 mb-2.5 rounded-[1.4rem] border border-[#ded2bd]/72 bg-[#fffaf1]/84 px-2.5 pb-3 pt-2 shadow-[0_12px_24px_rgba(74,59,42,0.11)]">
440 <div className="mb-1.5 flex items-center justify-end gap-1.5">
441 <Button
442 type="button"
443 size="sm"
444 variant="secondary"
445 disabled={imageControlsDisabled || !selectedPageExists}
446 className="h-6 rounded-full border border-[#8faf7d]/36 bg-[#dcebcf]/74 px-2.5 text-[11px] font-medium text-[#526942] shadow-none hover:bg-[#d2e4c3] disabled:opacity-45"
447 onClick={handleFillFromOutline}
448 >
449 {t('sessionDetail.imagePromptFromOutline')}
450 </Button>
451 <ModelSplitButton
452 modelAction={modelAction}
453 label={t('sessionDetail.imagePromptFromCurrentPage')}
454 loadingLabel={t('sessionDetail.imagePromptGenerating')}
455 loading={isGeneratingPrompt}
456 disabled={
457 imageControlsDisabled || !selectedPageExists || !sessionId || !selectedPageHtmlPath
458 }
459 icon={Sparkles}
460 tone="subtle"
461 size="sm"
462 dropdownAlign="end"
463 className="h-6 rounded-full border-[#8faf7d]/36 bg-[#dcebcf]/74 shadow-none"
464 mainClassName="h-6 rounded-full px-2.5 text-[11px] font-medium text-[#526942] hover:bg-[#d2e4c3] hover:text-[#526942]"
465 triggerClassName="h-6 text-[#526942] hover:bg-[#d2e4c3] hover:text-[#526942]"
466 onRun={handleGeneratePromptFromCurrentPage}
467 />
468 </div>
469 {!hasImageModels && (
470 <p className="mb-1.5 rounded-lg border border-[#e3d6c2]/78 bg-[#fff6e7]/82 px-2.5 py-1.5 text-[11px] leading-4 text-[#8a5d2d]">
471 {t('sessionDetail.imageModelRequiredHint')}
472 </p>
473 )}
474 <div>
475 <Textarea
476 placeholder={t('sessionDetail.imagePromptPlaceholder')}
477 value={imagePrompt}
478 onChange={(event) => setImagePrompt(event.target.value)}
479 disabled={imageControlsDisabled}
480 rows={4}
481 className="min-h-[96px] resize-none rounded-[1.15rem] border border-[#ded2bd]/72 bg-[#fffdf8]/88 px-3 py-2 text-[13px] leading-5 text-[#3f4b35] shadow-[inset_0_1px_2px_rgba(74,59,42,0.05)] focus-visible:border-[#9bb98a] focus-visible:ring-0 focus-visible:ring-offset-0"
482 />
483 </div>
484 <div className="mt-2 flex items-center justify-between gap-2">
485 <Popover>
486 <PopoverTrigger asChild>
487 <Button
488 type="button"
489 variant="secondary"
490 size="sm"
491 disabled={imageControlsDisabled}
492 className="h-8 min-w-0 flex-1 justify-start rounded-full border border-[#ded2bd]/70 bg-[#fffdf8]/88 px-2.5 text-xs text-[#52614a]"
493 >
494 <SlidersHorizontal className="mr-1.5 h-3.5 w-3.5 shrink-0" />
495 <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
496 {t('sessionDetail.imageConfigButton')}
497 </span>
498 </Button>
499 </PopoverTrigger>
500 <PopoverContent
501 side="top"
502 align="start"
503 className="w-[268px] border-[#d8cfbc]/80 bg-[#fffdf8] p-3"
504 >
505 <div className="space-y-3">
506 <div>
507 <p className="text-xs font-semibold text-[#34402c]">
508 {t('sessionDetail.imageConfigTitle')}
509 </p>
510 <p className="mt-0.5 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-muted-foreground">
511 {optionSummary}
512 </p>
513 </div>
514 <div>
515 <label className="mb-1 block text-[11px] font-medium text-[#6d604d]">
516 {t('sessionDetail.imageModelPlaceholder')}
517 </label>
518 <Select
519 value={selectedImageModelConfigId || undefined}
520 onValueChange={setSelectedImageModelConfigId}
521 disabled={imageControlsDisabled}
522 >
523 <SelectTrigger className="h-8 w-full min-w-0 rounded-lg border-[#ded2bd]/70 bg-[#fffdf8]/82 px-3 py-1 text-xs text-[#3e4a32] shadow-none">
524 <SelectValue placeholder={t('sessionDetail.imageModelPlaceholder')} />
525 </SelectTrigger>
526 <SelectContent>
527 {imageModelConfigs.map((config) => (
528 <SelectItem key={config.id} value={config.id}>
529 {config.name}
530 </SelectItem>
531 ))}
532 </SelectContent>
533 </Select>
534 </div>
535 <div className="grid grid-cols-2 gap-2">
536 <div>
537 <label className="mb-1 block text-[11px] font-medium text-[#6d604d]">
538 {t('sessionDetail.imageSizeLabel')}
539 </label>
540 <Select
541 value={selectedImageSizeOption?.value || imageSize}
542 onValueChange={setImageSize}
543 disabled={imageControlsDisabled}
544 >
545 <SelectTrigger className="h-8 w-full rounded-lg border-[#ded2bd]/70 bg-[#fffdf8]/82 px-3 py-1 text-xs text-[#3e4a32] shadow-none">
546 <SelectValue />
547 </SelectTrigger>
548 <SelectContent>
549 {imageSizeOptions.map((option) => (
550 <SelectItem key={option.value} value={option.value}>
551 {option.label}
552 </SelectItem>
553 ))}
554 </SelectContent>
555 </Select>
556 </div>
557 <div>
558 <label className="mb-1 block text-[11px] font-medium text-[#6d604d]">
559 {t('sessionDetail.imageCountLabel')}
560 </label>
561 <Select value="1" disabled>
562 <SelectTrigger className="h-8 w-full rounded-lg border-[#ded2bd]/70 bg-[#fffdf8]/82 px-3 py-1 text-xs text-[#3e4a32] shadow-none">
563 <SelectValue />
564 </SelectTrigger>
565 <SelectContent>
566 <SelectItem value="1">1</SelectItem>
567 </SelectContent>
568 </Select>
569 </div>
570 </div>
571 </div>
572 </PopoverContent>
573 </Popover>
574 {isGeneratingImage ? (
575 <Button
576 variant="destructive"
577 onClick={() => void cancel()}
578 size="sm"
579 className="shrink-0 whitespace-nowrap rounded-full px-3 text-xs shadow-[0_8px_18px_rgba(177,90,88,0.22)]"
580 >
581 <StopCircle className="mr-1 h-4 w-4" />
582 {t('sessionDetail.stop')}
583 </Button>
584 ) : (
585 <Button
586 size="sm"
587 disabled={generateDisabled}
588 onClick={() => void generate()}
589 className="h-8 shrink-0 whitespace-nowrap px-3 text-xs"
590 >
591 {isGeneratingImage && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
592 <Sparkles className="mr-1 h-4 w-4" />
593 {t('sessionDetail.generateImage')}
594 </Button>
595 )}
596 </div>
597 </div>
598 <Dialog open={Boolean(previewAsset)} onOpenChange={(open) => !open && setPreviewAsset(null)}>
599 <DialogContent className="w-[min(96vw,1080px)] max-w-none gap-3 border-[#d8cfbc]/80 bg-[#171b14] p-3 text-[#fffaf1] shadow-[0_28px_90px_rgba(12,15,10,0.42)] sm:p-4">
600 <DialogHeader className="pr-10">
601 <DialogTitle className="truncate text-sm text-[#fffaf1]">
602 {previewAsset?.fileName || t('sessionDetail.imagePreviewTitle')}
603 </DialogTitle>
604 </DialogHeader>
605 <div className="flex max-h-[76vh] min-h-[260px] items-center justify-center overflow-hidden rounded-lg bg-[#0d100b]">
606 {previewAsset?.absolutePath && (
607 <img
608 src={localAssetSrc(previewAsset.absolutePath)}
609 alt={previewAsset.fileName}
610 className="max-h-[76vh] max-w-full object-contain"
611 />
612 )}
613 </div>
614 {previewAsset && (
615 <div className="flex justify-end">
616 <div className="flex gap-2">
617 <Tooltip>
618 <TooltipTrigger asChild>
619 <Button
620 size="sm"
621 variant="secondary"
622 className="h-8 w-8 bg-[#fffaf1] p-0 text-[#34402c] hover:bg-[#efe5d5]"
623 disabled={!selectedPageExists}
624 onClick={() => void addToCanvas(previewAsset)}
625 aria-label={t('sessionDetail.addImageToCanvas')}
626 >
627 <ImagePlus className="h-3.5 w-3.5" />
628 </Button>
629 </TooltipTrigger>
630 <TooltipContent>{t('sessionDetail.addImageToCanvas')}</TooltipContent>
631 </Tooltip>
632 <Tooltip>
633 <TooltipTrigger asChild>
634 <Button
635 size="sm"
636 variant="secondary"
637 className="h-8 w-8 bg-[#fffaf1] p-0 text-[#34402c] hover:bg-[#efe5d5]"
638 disabled={!selectedPageExists}
639 onClick={() => void setAsBackground(previewAsset)}
640 aria-label={t('sessionDetail.setImageAsBackground')}
641 >
642 <Wallpaper className="h-3.5 w-3.5" />
643 </Button>
644 </TooltipTrigger>
645 <TooltipContent>{t('sessionDetail.setImageAsBackground')}</TooltipContent>
646 </Tooltip>
647 <Button
648 size="sm"
649 variant="secondary"
650 className="h-8 bg-[#fffaf1] px-2.5 text-xs text-[#34402c] hover:bg-[#efe5d5]"
651 onClick={() => void revealFile(previewAsset.absolutePath)}
652 >
653 <FolderOpen className="mr-1 h-3.5 w-3.5" />
654 {t('sessionDetail.revealFile')}
655 </Button>
656 </div>
657 </div>
658 )}
659 </DialogContent>
660 </Dialog>
661 </div>
662 )
663 }
664
664 lines Plain Text