ai-chatbot/components/qianwen-video-loading.tsx
Your Name dae5581c76
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: recognize runway video models in canvas chat
2026-03-20 23:51:43 +08:00

211 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { memo } from "react";
import { CircleDot } from "lucide-react";
import { SparklesIcon } from "./icons";
type VideoGenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed";
type VideoGenerationLoadingProps = {
providerType?: string;
modelName?: string;
phase?: VideoGenerationPhase;
elapsedSeconds?: number;
};
const PROVIDER_MODEL_LABEL: Record<string, string> = {
"qianwen-video": "千问视频",
"fal-video": "FAL 视频",
"ruxa-video": "RUXA 视频",
"volcano-video": "火山视频",
runway: "Runway 视频",
};
const PHASE_HINT: Record<VideoGenerationPhase, string> = {
idle: "任务已创建,正在初始化视频参数。",
queued: "任务已进入队列,请稍候。",
processing: "模型正在生成视频,请耐心等待。",
finalizing: "视频已生成完成,正在同步到对话。",
failed: "视频生成失败,正在尝试故障迁移。",
};
const PROVIDER_GRADIENT_CLASS: Record<string, string> = {
"qianwen-video": "from-blue-400 via-purple-400 to-pink-400",
"fal-video": "from-sky-400 via-cyan-400 to-indigo-400",
"ruxa-video": "from-violet-400 via-fuchsia-400 to-pink-400",
"volcano-video": "from-orange-400 via-amber-400 to-yellow-400",
runway: "from-rose-400 via-orange-400 to-amber-400",
};
/**
* 千问视频生成加载占位符组件
* 显示一个带有一闪一闪头像效果的动态卡片
*/
export const QianwenVideoLoading = memo(
({
providerType,
modelName,
phase = "processing",
elapsedSeconds = 0,
}: VideoGenerationLoadingProps) => {
const normalizedProviderType =
providerType === "doubao-video" ? "volcano-video" : providerType || "qianwen-video";
const resolvedModelName =
modelName?.trim() ||
PROVIDER_MODEL_LABEL[normalizedProviderType] ||
"AI 视频模型";
const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : "预计 60~180 秒";
const phaseHint = PHASE_HINT[phase];
const isFailed = phase === "failed";
const palette = isFailed
? {
ping: "bg-red-500/20",
pulse: "bg-red-500/10",
ring: "ring-red-500/30",
spinner: "text-red-500",
dot: "bg-red-500",
}
: {
ping: "bg-blue-500/20",
pulse: "bg-blue-500/10",
ring: "ring-blue-500/30",
spinner: "text-blue-500",
dot: "bg-blue-500",
};
const gradientClass =
PROVIDER_GRADIENT_CLASS[normalizedProviderType] || PROVIDER_GRADIENT_CLASS["qianwen-video"];
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-video-loading"
>
<div className="flex items-start justify-start gap-3">
{/* 头像 - 和图片生成一样的一闪一闪效果 */}
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
<div className={`absolute inset-0 rounded-full ${palette.ping} animate-ping`} />
<div className={`absolute inset-0 rounded-full ${palette.pulse} animate-pulse`} />
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${palette.ring}`}>
<div className={`animate-spin ${palette.spinner}`}>
<SparklesIcon size={14} />
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-[13px] text-[#a8b1bf]">
<CircleDot className="h-3.5 w-3.5 fill-current" />
<span className="text-[14px]">{resolvedModelName}</span>
</div>
{/* 文本提示 */}
<div className="flex items-center gap-1.5">
<span className="text-base font-medium text-foreground">
正在生成视频
</span>
<span className="flex gap-1">
<span
className={`h-2 w-2 rounded-full ${palette.dot} animate-bounce`}
style={{ animationDelay: "0ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${palette.dot} animate-bounce`}
style={{ animationDelay: "150ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${palette.dot} animate-bounce`}
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
{/* 视频占位符 - 170x200 像素,带动态效果 */}
<div
className="relative overflow-hidden rounded-xl"
style={{ width: "170px", height: "200px" }}
>
{/* 底层渐变背景 */}
<div className={`absolute inset-0 bg-gradient-to-br ${gradientClass} opacity-60`} />
{/* 动态模糊叠加层 1 */}
<div
className="absolute inset-0 bg-gradient-to-tr from-blue-500/40 via-transparent to-purple-500/40 animate-pulse"
style={{ animationDuration: "2s" }}
/>
{/* 动态模糊叠加层 2 */}
<div
className="absolute inset-0 bg-gradient-to-bl from-purple-500/30 via-transparent to-blue-500/30 animate-pulse"
style={{ animationDuration: "2.5s", animationDelay: "0.5s" }}
/>
{/* 噪点纹理效果 */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
}}
/>
{/* 模糊叠加 */}
<div className="absolute inset-0 backdrop-blur-sm" />
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* 外圈光晕 */}
<div className="absolute -inset-4 rounded-full bg-white/20 animate-ping" style={{ animationDuration: "1.5s" }} />
{/* 视频图标 */}
<svg
className="h-10 w-10 text-white/80 animate-pulse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ animationDuration: "1.5s" }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* 流光效果 */}
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
style={{
animation: "shimmer 2s infinite",
}}
/>
</div>
{/* 提示文字 */}
<div className="space-y-1 text-xs text-muted-foreground">
<p>{phaseHint}</p>
<p>{elapsedLabel}</p>
</div>
</div>
</div>
{/* 流光动画样式 */}
<style jsx>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
`}</style>
</div>
);
}
);
QianwenVideoLoading.displayName = "QianwenVideoLoading";