feat: save full generation params in image history with info popover
Docker Build / Build and Push Docker Image (push) Successful in 4m0s
Docker Build / Build and Push Docker Image (push) Successful in 4m0s
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import { Download, RotateCw, ZoomIn, Trash2, X, Clock, AlertCircle } from 'lucide-react'
|
||||
import { Download, RotateCw, ZoomIn, Trash2, X, Clock, AlertCircle, Info, Copy, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useState } from 'react'
|
||||
import type { GeneratedImage } from '../types'
|
||||
import type { GeneratedImage, GeneratedImageParams } from '../types'
|
||||
|
||||
type ImageResultsProps = {
|
||||
images: GeneratedImage[]
|
||||
@@ -17,6 +23,7 @@ type ImageResultsProps = {
|
||||
onDeleteImage?: (id: string) => void
|
||||
onClearAll?: () => void
|
||||
onClearFailed?: () => void
|
||||
onFillParams?: (params: GeneratedImageParams) => void
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -24,6 +31,126 @@ function formatDuration(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function formatParamsText(params: GeneratedImageParams): string {
|
||||
const lines = [
|
||||
`Prompt: ${params.prompt}`,
|
||||
`Model: ${params.model}`,
|
||||
`Group: ${params.group}`,
|
||||
`Size: ${params.size}`,
|
||||
`Quality: ${params.quality}`,
|
||||
`Style: ${params.style}`,
|
||||
`Count: ${params.n}`,
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function ImageParamsPopover({ image, onFillParams }: { image: GeneratedImage; onFillParams?: (params: GeneratedImageParams) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const params = image.params
|
||||
if (!params) return null
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatParamsText(params))
|
||||
toast.success(t('Copied'))
|
||||
} catch {
|
||||
toast.error(t('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFill = () => {
|
||||
onFillParams?.(params)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className='size-3.5' />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-72 p-3'
|
||||
side='right'
|
||||
align='start'
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs font-medium'>{t('Generation Parameters')}</span>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-6'
|
||||
onClick={handleCopy}
|
||||
title={t('Copy')}
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
{onFillParams && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-6'
|
||||
onClick={handleFill}
|
||||
title={t('Reuse parameters')}
|
||||
>
|
||||
<ArrowRight className='size-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1 text-xs'>
|
||||
<div className='rounded bg-muted p-2'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Prompt')}: </span>
|
||||
<span className='select-all break-all'>{params.prompt}</span>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-1'>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Model')}: </span>
|
||||
<span className='select-all'>{params.model}</span>
|
||||
</div>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Group')}: </span>
|
||||
<span className='select-all'>{params.group}</span>
|
||||
</div>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Size')}: </span>
|
||||
<span className='select-all'>{params.size}</span>
|
||||
</div>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Quality')}: </span>
|
||||
<span className='select-all'>{params.quality}</span>
|
||||
</div>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Style')}: </span>
|
||||
<span className='select-all'>{params.style}</span>
|
||||
</div>
|
||||
<div className='rounded bg-muted px-2 py-1'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Count')}: </span>
|
||||
<span className='select-all'>{params.n}</span>
|
||||
</div>
|
||||
</div>
|
||||
{image.revisedPrompt && (
|
||||
<div className='rounded bg-muted p-2'>
|
||||
<span className='font-medium text-muted-foreground'>{t('Revised Prompt')}: </span>
|
||||
<span className='select-all break-all'>{image.revisedPrompt}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageResults({
|
||||
images,
|
||||
isGenerating,
|
||||
@@ -32,6 +159,7 @@ export function ImageResults({
|
||||
onDeleteImage,
|
||||
onClearAll,
|
||||
onClearFailed,
|
||||
onFillParams,
|
||||
}: ImageResultsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [previewImage, setPreviewImage] = useState<GeneratedImage | null>(null)
|
||||
@@ -178,7 +306,7 @@ export function ImageResults({
|
||||
<>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.revisedPrompt || image.prompt || 'Generated image'}
|
||||
alt={image.revisedPrompt || image.params?.prompt || 'Generated image'}
|
||||
className='aspect-square w-full object-cover transition-transform group-hover:scale-105'
|
||||
/>
|
||||
{/* Duration badge */}
|
||||
@@ -188,23 +316,22 @@ export function ImageResults({
|
||||
{formatDuration(image.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{/* Prompt badge */}
|
||||
{image.prompt && (
|
||||
<span className='absolute left-1.5 bottom-1.5 right-1.5 line-clamp-2 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
{image.prompt}
|
||||
</span>
|
||||
)}
|
||||
{/* Delete button */}
|
||||
{onDeleteImage && (
|
||||
<button
|
||||
type='button'
|
||||
className='absolute right-1.5 top-1.5 z-10 hidden size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80 group-hover:flex'
|
||||
onClick={() => onDeleteImage(image.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
</button>
|
||||
)}
|
||||
{/* Top-right buttons: info + delete */}
|
||||
<div className='absolute right-1.5 top-1.5 z-10 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
{image.params && (
|
||||
<ImageParamsPopover image={image} onFillParams={onFillParams} />
|
||||
)}
|
||||
{onDeleteImage && (
|
||||
<button
|
||||
type='button'
|
||||
className='flex size-6 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80'
|
||||
onClick={() => onDeleteImage(image.id)}
|
||||
aria-label={t('Delete')}
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Hover actions */}
|
||||
<div className='pointer-events-none absolute inset-0 flex items-end justify-center bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<div className='flex gap-1 pb-2 pointer-events-auto'>
|
||||
|
||||
+35
-2
@@ -33,6 +33,7 @@ import {
|
||||
} from './types'
|
||||
import type {
|
||||
GeneratedImage,
|
||||
GeneratedImageParams,
|
||||
GroupOption,
|
||||
ImagePlaygroundConfig,
|
||||
ModelOption,
|
||||
@@ -295,6 +296,15 @@ export function ImagePlayground() {
|
||||
}
|
||||
|
||||
const durationMs = performance.now() - startTimeRef.current
|
||||
const generationParams = {
|
||||
prompt: prompt.trim(),
|
||||
model: config.model,
|
||||
group: config.group,
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
style: config.style,
|
||||
n: generationCount,
|
||||
}
|
||||
const newImages: GeneratedImage[] = (response.data || []).map(
|
||||
(item, index) => ({
|
||||
id: pendingImages[index]?.id || `${Date.now()}-${index}`,
|
||||
@@ -303,7 +313,7 @@ export function ImagePlayground() {
|
||||
(item.b64_json
|
||||
? `data:image/png;base64,${item.b64_json}`
|
||||
: ''),
|
||||
prompt: prompt.trim(),
|
||||
params: generationParams,
|
||||
revisedPrompt: item.revised_prompt,
|
||||
status: 'success' as const,
|
||||
durationMs,
|
||||
@@ -390,6 +400,15 @@ export function ImagePlayground() {
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
const item = response.data[0]
|
||||
const retryParams = {
|
||||
prompt: prompt.trim(),
|
||||
model: config.model,
|
||||
group: config.group,
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
style: config.style,
|
||||
n: 1,
|
||||
}
|
||||
setImages((prev) =>
|
||||
prev.map((img) =>
|
||||
img.id === id
|
||||
@@ -400,7 +419,7 @@ export function ImagePlayground() {
|
||||
(item.b64_json
|
||||
? `data:image/png;base64,${item.b64_json}`
|
||||
: ''),
|
||||
prompt: prompt.trim(),
|
||||
params: retryParams,
|
||||
revisedPrompt: item.revised_prompt,
|
||||
status: 'success' as const,
|
||||
durationMs,
|
||||
@@ -441,6 +460,19 @@ export function ImagePlayground() {
|
||||
setImages((prev) => prev.filter((img) => img.status !== 'failed'))
|
||||
}, [])
|
||||
|
||||
const handleFillParams = useCallback((params: GeneratedImageParams) => {
|
||||
setPrompt(params.prompt)
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
model: params.model,
|
||||
group: params.group,
|
||||
size: params.size,
|
||||
quality: params.quality,
|
||||
style: params.style,
|
||||
n: params.n,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
{/* Header */}
|
||||
@@ -573,6 +605,7 @@ export function ImagePlayground() {
|
||||
onDeleteImage={handleDeleteImage}
|
||||
onClearAll={handleClearAll}
|
||||
onClearFailed={handleClearFailed}
|
||||
onFillParams={handleFillParams}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -29,10 +29,20 @@ export interface ImageGenerationResponse {
|
||||
|
||||
export type GeneratedImageStatus = 'pending' | 'success' | 'failed'
|
||||
|
||||
export interface GeneratedImageParams {
|
||||
prompt: string
|
||||
model: string
|
||||
group: string
|
||||
size: string
|
||||
quality: string
|
||||
style: string
|
||||
n: number
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
id: string
|
||||
url: string
|
||||
prompt?: string
|
||||
params?: GeneratedImageParams
|
||||
revisedPrompt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
|
||||
Vendored
+4
-1
@@ -4900,6 +4900,9 @@
|
||||
"Upload": "上传",
|
||||
"Ref": "参考",
|
||||
"Remove": "移除",
|
||||
"Click or drag to upload reference images": "点击或拖拽上传参考图"
|
||||
"Click or drag to upload reference images": "点击或拖拽上传参考图",
|
||||
"Generation Parameters": "生成参数",
|
||||
"Reuse parameters": "填入参数",
|
||||
"Revised Prompt": "修订提示词"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user