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