feat(chat): 为媒体工具添加骨架屏占位并优化管线进度组件
- 在 ChatMessage 组件中为图片/视频生成工具添加骨架屏占位卡片 - 优化 PipelineProgress 组件,使用阶段标签和状态指示器替代进度条 - 添加骨架屏 shimmer 动画样式
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { renderMarkdown } from '@/lib/markdown';
|
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 { ConfirmDialog } from '@/components/ui/confirm';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
@@ -59,6 +59,21 @@ function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkeletonCard({ type }: { type: 'image' | 'video' }) {
|
||||||
|
return (
|
||||||
|
<div className="w-24 h-24 rounded-lg border border-zinc-200/80 bg-gradient-to-br from-zinc-50 to-zinc-100/80 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{type === 'image' ? (
|
||||||
|
<ImageIcon size={18} className="text-zinc-300/70" />
|
||||||
|
) : (
|
||||||
|
<Film size={18} className="text-zinc-300/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 skeleton-shimmer" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type MessageState = 'streaming' | 'thinking' | 'done';
|
type MessageState = 'streaming' | 'thinking' | 'done';
|
||||||
|
|
||||||
interface Props {
|
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 (
|
return (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -155,11 +177,11 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assets grid - images and video thumbnails */}
|
{/* Assets + Skeleton cards grid */}
|
||||||
{message.assets && message.assets.length > 0 && (
|
{(message.assets && message.assets.length > 0) || (isRunning && isMediaTool && remainingCount > 0) ? (
|
||||||
<div className="px-3 pb-3 pt-1">
|
<div className="px-3 pb-3 pt-1">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{message.assets.map((asset, i) => (
|
{message.assets?.map((asset, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setPreviewIndex(i)}
|
onClick={() => setPreviewIndex(i)}
|
||||||
@@ -185,18 +207,18 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
|
|||||||
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
|
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{isRunning && isMediaTool && remainingCount > 0 && Array.from({ length: remainingCount }, (_, i) => (
|
||||||
|
<SkeletonCard key={`skel-${i}`} type={mediaType} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : isRunning ? (
|
||||||
|
|
||||||
{/* Running animation - liquid fill wave */}
|
|
||||||
{isRunning && (
|
|
||||||
<div className="px-3 pb-3">
|
<div className="px-3 pb-3">
|
||||||
<div className="tool-progress-track h-1.5 w-full rounded-full bg-indigo-100/60 overflow-hidden">
|
<div className="tool-progress-track h-1.5 w-full rounded-full bg-indigo-100/60 overflow-hidden">
|
||||||
<div className="tool-shimmer-bar h-full" />
|
<div className="tool-shimmer-bar h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -8,34 +9,49 @@ interface Props {
|
|||||||
status?: string;
|
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) {
|
export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) {
|
||||||
const phaseLabel: Record<string, string> = {
|
const currentIdx = PHASES.findIndex(p => p.key === phase);
|
||||||
images: '生成图片',
|
|
||||||
upload: '上传素材',
|
|
||||||
videos: '生成视频',
|
|
||||||
tts: '配音',
|
|
||||||
assemble: '成片组装',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-br from-zinc-900 to-zinc-800 border border-zinc-700/50 rounded-xl p-3.5 my-2 shadow-lg shadow-zinc-900/20">
|
<div className="bg-gradient-to-br from-zinc-900 to-zinc-800 border border-zinc-700/50 rounded-xl p-3.5 my-2 shadow-lg shadow-zinc-900/20">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-zinc-400 font-medium">
|
{PHASES.map((p, i) => {
|
||||||
{phaseLabel[phase] || phase}
|
const isDone = i < currentIdx || (i === currentIdx && progress >= 100);
|
||||||
{currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
|
const isActive = i === currentIdx && progress < 100;
|
||||||
</span>
|
|
||||||
<span className="text-xs font-mono text-zinc-500">{progress}%</span>
|
return (
|
||||||
</div>
|
<div key={p.key} className={cn(
|
||||||
<div className="w-full h-1.5 bg-zinc-700/50 rounded-full overflow-hidden">
|
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all duration-300',
|
||||||
<div
|
isDone ? 'bg-emerald-500/15 text-emerald-400' :
|
||||||
className={cn(
|
isActive ? 'bg-indigo-500/15 text-indigo-400 ring-1 ring-indigo-400/30' :
|
||||||
'h-full rounded-full transition-all duration-700 ease-out',
|
'bg-zinc-800/50 text-zinc-600'
|
||||||
progress < 100 ? 'bg-gradient-to-r from-blue-500 to-indigo-400' : 'bg-gradient-to-r from-emerald-500 to-green-400'
|
)}>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle2 size={12} className="text-emerald-400" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 size={12} className="text-indigo-400 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-zinc-600" />
|
||||||
|
)}
|
||||||
|
<span>{p.label}</span>
|
||||||
|
{isActive && currentItem != null && totalItems != null && (
|
||||||
|
<span className="text-[10px] opacity-60 ml-0.5">
|
||||||
|
{currentItem}/{totalItems}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{status && <p className="text-xs text-zinc-500 mt-1.5">{status}</p>}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{status && <p className="text-xs text-zinc-500 mt-2">{status}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
animation: liquid-glow 2.4s ease-in-out infinite;
|
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 */
|
/* GitHub-style Markdown - light theme */
|
||||||
@layer components {
|
@layer components {
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
|
|||||||
Reference in New Issue
Block a user