'use client'; import { ImageCompareView, resolveCompareTargetIndex } from '@/components/image-compare-view'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useI18n } from '@/lib/i18n'; import { getActivityLogLevelLabel } from '@/lib/image-display-labels'; import { buildLogScopeDiagnostics, filterLogsByScope, resolveLogClientRequestIds } from '@/lib/log-filter'; import { cn } from '@/lib/utils'; import { Copy, Download, GitCompare, Grid, Loader2, RefreshCcw, Send, Share2, Activity, Trash2, ImageIcon } from 'lucide-react'; import Image from 'next/image'; import * as React from 'react'; type ImageInfo = { path: string; filename: string; clientRequestId?: string; storageMode?: 'fs' | 'indexeddb'; }; type ImageActionTarget = { filename: string; storageMode?: ImageInfo['storageMode']; }; type ImageOutputProps = { imageBatch: ImageInfo[] | null; viewMode: 'grid' | number; onViewChange: (view: 'grid' | number) => void; altText?: string; isLoading: boolean; onSendToEdit: (filename: string, storageMode?: ImageInfo['storageMode']) => void; onDownloadImage: (filename: string, storageMode?: ImageInfo['storageMode']) => void; onShareImage: (filename: string, storageMode?: ImageInfo['storageMode']) => void; onCreateVariant: () => void; onReusePrompt: () => void; failureMessage?: string | null; onRetry?: () => void; compareImage?: ImageInfo | null; compareImageLabel?: string; canCreateVariant: boolean; canReusePrompt: boolean; currentMode: 'generate' | 'edit'; baseImagePreviewUrl: string | null; streamingPreviewImages?: Map; isStreamingRequest?: boolean; clientPasswordHash: string | null; canOpenLogs: boolean; openLogsSignal?: number; logClientRequestIds?: string[]; logFilenames?: string[]; }; type LogEntry = { id: number; at: string; level: 'debug' | 'info' | 'warn' | 'error'; message: string; context?: string; clientRequestId?: string; filenames?: string[]; }; type ImageDimensions = { width: number; height: number; }; export function buildImageActionTarget(image: ImageInfo | null): ImageActionTarget | null { if (!image) return null; return { filename: image.filename, ...(image.storageMode ? { storageMode: image.storageMode } : {}) }; } export function formatLogTime(value: string, locale: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleTimeString(locale); } function formatLogScopeValues(values: string[]): string { return values.length > 0 ? values.join(', ') : '-'; } function readImageDimensionsFromSource(source: string): Promise { return new Promise((resolve) => { const image = new window.Image(); image.onload = () => { if (!image.naturalWidth || !image.naturalHeight) { resolve(null); return; } resolve({ width: image.naturalWidth, height: image.naturalHeight }); }; image.onerror = () => resolve(null); image.src = source; }); } const getGridColsClass = (count: number): string => { if (count <= 1) return 'grid-cols-1'; if (count <= 4) return 'grid-cols-2'; if (count <= 9) return 'grid-cols-3'; return 'grid-cols-3'; }; type ResultActionButtonProps = { icon: React.ReactNode; label: string; onClick?: () => void; disabled?: boolean; active?: boolean; emphasized?: boolean; iconOnly?: boolean; }; function ResultActionButton({ icon, label, onClick, disabled = false, active = false, emphasized = false, iconOnly = false }: ResultActionButtonProps) { return ( ); } export function ImageOutput({ imageBatch, viewMode, onViewChange, altText, isLoading, onSendToEdit, onDownloadImage, onShareImage, onCreateVariant, onReusePrompt, failureMessage = null, onRetry, compareImage = null, compareImageLabel, canCreateVariant, canReusePrompt, currentMode, baseImagePreviewUrl, streamingPreviewImages, isStreamingRequest = false, clientPasswordHash, canOpenLogs, openLogsSignal, logClientRequestIds = [], logFilenames = [] }: ImageOutputProps) { const { locale, t } = useI18n(); const resolvedAltText = altText ?? t('output.alt'); const [isLogDialogOpen, setIsLogDialogOpen] = React.useState(false); const [logs, setLogs] = React.useState([]); const [logConnectionState, setLogConnectionState] = React.useState<'idle' | 'connected' | 'error'>('idle'); const [copiedLogScopeText, setCopiedLogScopeText] = React.useState(null); const [compareSelection, setCompareSelection] = React.useState<{ imageBatch: ImageInfo[]; selectedImageIndex: number; compareTargetFilename: string; } | null>(null); const [imageDimensions, setImageDimensions] = React.useState>({}); const logEndRef = React.useRef(null); const resolvedLogClientRequestIds = React.useMemo( () => resolveLogClientRequestIds({ logs, clientRequestIds: logClientRequestIds, filenames: logFilenames }), [logClientRequestIds, logFilenames, logs] ); const filteredLogs = React.useMemo( () => filterLogsByScope({ logs, clientRequestIds: resolvedLogClientRequestIds, filenames: [] }) as LogEntry[], [logs, resolvedLogClientRequestIds] ); const logScopeDiagnostics = React.useMemo( () => buildLogScopeDiagnostics({ clientRequestIds: logClientRequestIds, filenames: logFilenames, resolvedClientRequestIds: resolvedLogClientRequestIds }), [logClientRequestIds, logFilenames, resolvedLogClientRequestIds] ); const hasSelectedImageBatch = !!imageBatch && imageBatch.length > 0; const hasLogScope = resolvedLogClientRequestIds.length > 0; const hasScopeCandidate = logClientRequestIds.length > 0 || logFilenames.length > 0; const visibleLogs = React.useMemo(() => (hasLogScope ? filteredLogs : []), [filteredLogs, hasLogScope]); const showCarousel = Boolean(imageBatch && imageBatch.length > 1); const selectedImageIndex = imageBatch && imageBatch.length > 0 ? typeof viewMode === 'number' && imageBatch[viewMode] ? viewMode : showCarousel ? null : 0 : null; const selectedImage = selectedImageIndex === null ? null : imageBatch?.[selectedImageIndex] || null; const sameBatchCompareTargetIndex = imageBatch ? resolveCompareTargetIndex(imageBatch.length, selectedImageIndex) : null; const sameBatchCompareTargetImage = sameBatchCompareTargetIndex === null ? null : imageBatch?.[sameBatchCompareTargetIndex] || null; const compareTargetImage = sameBatchCompareTargetImage || compareImage || null; const selectedImageDimensions = selectedImage ? imageDimensions[selectedImage.filename] : null; const hasFailure = !isLoading && !hasSelectedImageBatch && Boolean(failureMessage); const hasEditReferencePreview = currentMode === 'edit' && Boolean(baseImagePreviewUrl); const isGenerateEmptyState = !isLoading && !hasFailure && !hasSelectedImageBatch && currentMode === 'generate'; const previewStateLabel = isLoading ? t('output.progressDeveloping') : hasFailure ? t('output.failedTitle') : hasEditReferencePreview ? t('output.editReferenceReady') : currentMode === 'edit' ? t('output.editReferenceNeeded') : imageBatch && imageBatch.length > 0 ? t('output.previewReady') : t('output.emptyTitle'); const previewMetaItems = [ !isLoading && imageBatch && imageBatch.length > 0 ? selectedImageIndex === null ? t('output.selectImageForActions') : t('output.selectedImageMeta', { index: selectedImageIndex + 1, count: imageBatch.length }) : null, !isLoading && selectedImageDimensions ? t('output.imageDimensions', { width: selectedImageDimensions.width, height: selectedImageDimensions.height }) : null, isLoading ? (isStreamingRequest ? t('output.streaming') : t('output.progressGenerating')) : null ].filter((item): item is string => Boolean(item)); const canUseSelectedImageActions = !isLoading && !!selectedImage; const canCompareImages = !isLoading && !!selectedImage && !!compareTargetImage; const isCompareView = !!compareSelection && compareSelection.imageBatch === imageBatch && compareSelection.selectedImageIndex === selectedImageIndex && compareSelection.compareTargetFilename === compareTargetImage?.filename; const compareReferenceLabel = sameBatchCompareTargetImage ? selectedImageIndex === 0 ? t('output.compareOther') : t('output.compareReference') : (compareImageLabel ?? t('output.compareReference')); const handleSendClick = () => { const target = buildImageActionTarget(selectedImage); if (!target) return; onSendToEdit(target.filename, target.storageMode); }; const handleDownloadClick = () => { const target = buildImageActionTarget(selectedImage); if (!target) return; onDownloadImage(target.filename, target.storageMode); }; const handleShareClick = () => { const target = buildImageActionTarget(selectedImage); if (!target) return; onShareImage(target.filename, target.storageMode); }; const handleCompareClick = () => { if (canCompareImages && imageBatch && selectedImageIndex !== null && compareTargetImage) { setCompareSelection({ imageBatch, selectedImageIndex, compareTargetFilename: compareTargetImage.filename }); } }; const handleImageLoad = React.useCallback((filename: string, event: React.SyntheticEvent) => { const source = event.currentTarget.currentSrc || event.currentTarget.src; if (!source) return; void readImageDimensionsFromSource(source).then((nextDimensions) => { if (!nextDimensions) return; setImageDimensions((current) => { const currentDimensions = current[filename]; if ( currentDimensions?.width === nextDimensions.width && currentDimensions.height === nextDimensions.height ) { return current; } return { ...current, [filename]: nextDimensions }; }); }); }, []); const handleLogDialogOpenChange = (open: boolean) => { setIsLogDialogOpen(open); if (!open) { setLogConnectionState('idle'); setCopiedLogScopeText(null); } }; const handleCopyLogScope = async () => { try { if (!navigator.clipboard?.writeText) { throw new Error('Clipboard API unavailable.'); } await navigator.clipboard.writeText(logScopeDiagnostics.copyText); setCopiedLogScopeText(logScopeDiagnostics.copyText); window.setTimeout(() => { setCopiedLogScopeText((current) => (current === logScopeDiagnostics.copyText ? null : current)); }, 1500); } catch (error) { console.error('复制日志诊断范围失败。', error); } }; React.useEffect(() => { if (!isLogDialogOpen || !canOpenLogs) return; const abortController = new AbortController(); const readLogs = async () => { try { const response = await fetch('/api/logs', { headers: clientPasswordHash ? { Authorization: `Bearer ${clientPasswordHash}` } : {}, signal: abortController.signal }); if (!response.ok || !response.body) { setLogConnectionState('error'); return; } setLogConnectionState('connected'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const processEvent = (rawEvent: string) => { const dataLines = rawEvent .split(/\r?\n/) .filter((line) => line.startsWith('data: ')) .map((line) => line.slice(6)); if (dataLines.length === 0) return; let entry: LogEntry; try { entry = JSON.parse(dataLines.join('\n')) as LogEntry; } catch (error) { console.error('解析日志事件失败。', error); return; } setLogs((prevLogs) => { const nextLogs = prevLogs.some((log) => log.id === entry.id) ? prevLogs : [...prevLogs, entry].slice(-300); return nextLogs; }); }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split(/\n\n|\r\n\r\n/); buffer = events.pop() || ''; events.forEach(processEvent); } const remainingEvent = buffer.trim(); if (remainingEvent) { processEvent(remainingEvent); } setLogConnectionState('idle'); } catch (error) { if (abortController.signal.aborted) return; console.error('读取日志流失败。', error); setLogConnectionState('error'); } }; void readLogs(); return () => { abortController.abort(); }; }, [canOpenLogs, clientPasswordHash, isLogDialogOpen]); React.useEffect(() => { if (!isLogDialogOpen) return; logEndRef.current?.scrollIntoView({ block: 'end' }); }, [isLogDialogOpen, visibleLogs]); React.useEffect(() => { if (!openLogsSignal || !canOpenLogs) return; queueMicrotask(() => setIsLogDialogOpen(true)); }, [canOpenLogs, openLogsSignal]); return (

{t('output.previewTitle')}

{isLoading ? ( ) : ( 0 ? 'bg-emerald-500' : 'bg-muted-foreground/45' )} /> )} {previewStateLabel} {previewMetaItems.map((item) => ( {item} ))}
{isLoading ? ( streamingPreviewImages && streamingPreviewImages.size > 0 ? ( // 展示流式预览图,单图时和最终视图一样居中。
{/* 展示最新的预览图,也就是最大索引图片。 */} {(() => { const entries = Array.from(streamingPreviewImages.entries()); const latestEntry = entries[entries.length - 1]; if (!latestEntry) return null; const [, dataUrl] = latestEntry; return ( {t('output.streaming')} ); })()} {/* 在底部居中叠加加载状态。 */}

{t('output.streaming')}

) : currentMode === 'edit' && baseImagePreviewUrl ? (
{t('output.editing')}

{t('output.editing')}

) : (

{isStreamingRequest ? t('output.keepalive') : t('output.generating')}

) ) : hasFailure ? (
{t('output.failedKicker')}

{t('output.failedTitle')}

{failureMessage}

{onRetry ? ( ) : null} {canOpenLogs ? ( ) : null}
) : isCompareView && selectedImage && compareTargetImage ? ( ) : imageBatch && imageBatch.length > 0 ? ( viewMode === 'grid' ? (
{imageBatch.map((img, index) => ( ))}
) : imageBatch[viewMode] ? (
{resolvedAltText} handleImageLoad(imageBatch[viewMode].filename, event)} unoptimized />
) : (

{t('output.error')}

) ) : currentMode === 'edit' ? ( baseImagePreviewUrl ? (
{t('output.editReferenceAlt')}
{t('output.editReferenceReadyLabel')}
) : (

{t('output.editReferenceNeeded')}

{t('output.editReferenceDescription')}

{t('output.editReferenceWaitingLabel')}
) ) : (

{t('output.emptyTitle')}

{t('output.emptyDescription')}

)}
{t('logs.title')} {t('logs.description')}
{t(`logs.status.${logConnectionState}`)} {t('logs.count', { count: visibleLogs.length })}
{hasLogScope ? (
{t('logs.scopeSelected')}
) : hasSelectedImageBatch ? (
{t('logs.scopeMissing')}
) : (
{t('logs.scopeNone')}
)}

{t('logs.scopeRequestIds', { value: formatLogScopeValues(logScopeDiagnostics.requestIds) })}

{t('logs.scopeFilenames', { value: formatLogScopeValues(logScopeDiagnostics.filenames) })}

{t('logs.scopeResolvedRequestIds', { value: formatLogScopeValues(logScopeDiagnostics.filenameMatchedRequestIds) })}

{visibleLogs.length === 0 ? (

{hasLogScope ? t('logs.emptyForSelection') : hasSelectedImageBatch && !hasScopeCandidate ? t('logs.historyWithoutScope') : t('logs.selectImage')}

) : ( visibleLogs.map((entry) => (
{formatLogTime(entry.at, locale)} {getActivityLogLevelLabel(entry.level, t)} {entry.message}
{entry.context ? (
                                            {entry.context}
                                        
) : null}
)) )}
{hasSelectedImageBatch ? ( <> {showCarousel && (
{imageBatch.map((img, index) => ( ))}
)} {canOpenLogs && ( } label={t('logs.open')} onClick={() => setIsLogDialogOpen(true)} /> )} } label={t('output.download')} onClick={handleDownloadClick} disabled={!canUseSelectedImageActions} emphasized={showCarousel && viewMode === 'grid'} /> } label={t('output.continueEdit')} onClick={handleSendClick} disabled={!canUseSelectedImageActions} emphasized={showCarousel && viewMode === 'grid'} /> } label={t('output.createVariant')} onClick={onCreateVariant} disabled={isLoading || !canCreateVariant} /> } label={t('output.reusePrompt')} onClick={onReusePrompt} disabled={isLoading || !canReusePrompt} /> } label={t('output.compare')} onClick={handleCompareClick} disabled={!canCompareImages} active={isCompareView} /> } label={t('output.share')} onClick={handleShareClick} disabled={!canUseSelectedImageActions} emphasized={showCarousel && viewMode === 'grid'} /> ) : ( <> } label={t('output.download')} disabled /> } label={t('output.continueEdit')} disabled /> } label={t('output.createVariant')} disabled /> } label={t('output.reusePrompt')} disabled /> } label={t('output.compare')} disabled /> )}
); }