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 {