diff --git a/web/client/src/components/chat/ChatMessage.tsx b/web/client/src/components/chat/ChatMessage.tsx index afeb82d..ee61835 100644 --- a/web/client/src/components/chat/ChatMessage.tsx +++ b/web/client/src/components/chat/ChatMessage.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { renderMarkdown } from '@/lib/markdown'; -import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play, Loader2, CheckCircle2, XCircle, Image as ImageIcon, Film } from 'lucide-react'; import { ConfirmDialog } from '@/components/ui/confirm'; import { useConfirm } from '@/hooks/useConfirm'; import { useToast } from '@/hooks/useToast'; @@ -59,6 +59,21 @@ function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: { ); } +function SkeletonCard({ type }: { type: 'image' | 'video' }) { + return ( +
+
+ {type === 'image' ? ( + + ) : ( + + )} +
+
+
+ ); +} + type MessageState = 'streaming' | 'thinking' | 'done'; interface Props { @@ -124,6 +139,13 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti } } + // Media tool skeleton logic + const isMediaTool = toolName === 'generate_images' || toolName === 'generate_videos'; + const mediaType: 'image' | 'video' = toolName === 'generate_images' ? 'image' : 'video'; + const expectedCount = isMediaTool && message.toolInput?.count ? (message.toolInput.count as number) : 0; + const assetCount = message.assets?.length || 0; + const remainingCount = isRunning ? Math.max(0, expectedCount - assetCount) : 0; + return (
- {/* Assets grid - images and video thumbnails */} - {message.assets && message.assets.length > 0 && ( + {/* Assets + Skeleton cards grid */} + {(message.assets && message.assets.length > 0) || (isRunning && isMediaTool && remainingCount > 0) ? (
- {message.assets.map((asset, i) => ( + {message.assets?.map((asset, i) => ( ))} + {isRunning && isMediaTool && remainingCount > 0 && Array.from({ length: remainingCount }, (_, i) => ( + + ))}
- )} - - {/* Running animation - liquid fill wave */} - {isRunning && ( + ) : isRunning ? (
- )} + ) : null}
{/* Lightbox */} diff --git a/web/client/src/components/chat/PipelineProgress.tsx b/web/client/src/components/chat/PipelineProgress.tsx index f44487c..33a2caa 100644 --- a/web/client/src/components/chat/PipelineProgress.tsx +++ b/web/client/src/components/chat/PipelineProgress.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { CheckCircle2, Loader2 } from 'lucide-react'; interface Props { phase: string; @@ -8,34 +9,49 @@ interface Props { status?: string; } +const PHASES = [ + { key: 'images', label: '图片' }, + { key: 'upload', label: '上传' }, + { key: 'videos', label: '视频' }, + { key: 'tts', label: '配音' }, + { key: 'assemble', label: '成片' }, +]; + export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) { - const phaseLabel: Record = { - images: '生成图片', - upload: '上传素材', - videos: '生成视频', - tts: '配音', - assemble: '成片组装', - }; + const currentIdx = PHASES.findIndex(p => p.key === phase); return (
-
- - {phaseLabel[phase] || phase} - {currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''} - - {progress}% +
+ {PHASES.map((p, i) => { + const isDone = i < currentIdx || (i === currentIdx && progress >= 100); + const isActive = i === currentIdx && progress < 100; + + return ( +
+ {isDone ? ( + + ) : isActive ? ( + + ) : ( +
+ )} + {p.label} + {isActive && currentItem != null && totalItems != null && ( + + {currentItem}/{totalItems} + + )} +
+ ); + })}
-
-
-
- {status &&

{status}

} + {status &&

{status}

}
); } diff --git a/web/client/src/index.css b/web/client/src/index.css index c57817a..ed27c51 100644 --- a/web/client/src/index.css +++ b/web/client/src/index.css @@ -65,6 +65,16 @@ animation: liquid-glow 2.4s ease-in-out infinite; } +/* Skeleton card shimmer */ +@keyframes shimmer-slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} +.skeleton-shimmer { + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%); + animation: shimmer-slide 1.5s ease-in-out infinite; +} + /* GitHub-style Markdown - light theme */ @layer components { .markdown-body {