feat: save full generation params in image history with info popover
Docker Build / Build and Push Docker Image (push) Successful in 4m0s

This commit is contained in:
2026-06-23 04:43:41 +08:00
parent bc505e6cec
commit 71be2a2b4e
4 changed files with 197 additions and 24 deletions
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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": "修订提示词"
}
}