ai-chatbot/components/image-generation-loading.tsx
Your Name 5d8146ca11
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: surface image queue status across generators
2026-05-18 23:48:49 +08:00

180 lines
6.3 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 { ChevronDown, Grid2x2, CircleDot } from "lucide-react";
import { cn } from "@/lib/utils";
type ColorTheme = "purple" | "blue" | "green" | "orange" | "red";
interface ImageGenerationLoadingProps {
imageCount?: number;
maxImages?: number;
colorTheme?: ColorTheme;
modelName?: string;
providerType?: string;
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
elapsedSeconds?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
}
const PROVIDER_MODEL_LABEL: Record<string, string> = {
"alibaba-image": "千问图片",
"ruxa-image": "RUXA 图片",
"fal-image": "FAL 图片",
"openai-image": "OpenAI 图片",
"stepfun-image": "StepFun 图片",
midjourney: "Midjourney",
"volcano-vision": "即梦 4.5",
gemini: "Gemini 图片",
krea: "Krea 图片",
};
const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, string> = {
idle: "任务已创建,正在初始化生成参数。",
queued: "任务已进入队列,请稍候。",
processing: "模型正在生成图片,请稍候。",
finalizing: "图片已生成完成,正在同步到对话。",
failed: "生成失败,请重试。",
};
function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
const numeric = Number(estimatedWaitMs || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "预计即将开始";
}
const seconds = Math.max(1, Math.round(numeric / 1000));
if (seconds < 60) {
return `预计 ${seconds} 秒`;
}
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
return remainSeconds > 0
? `预计 ${minutes} 分 ${remainSeconds} 秒`
: `预计 ${minutes} 分钟`;
}
export const ImageGenerationLoading = memo(
({
imageCount = 1,
maxImages = 6,
modelName,
providerType,
phase = "processing",
elapsedSeconds = 0,
queuePosition,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs,
}: ImageGenerationLoadingProps) => {
const count = Math.min(Math.max(imageCount, 1), maxImages);
const resolvedModelName =
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
const estimateRangeLabel = "60~120秒";
const elapsedLabel =
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`;
const isFailed = phase === "failed";
const queueLine =
phase === "queued" && Number.isFinite(queuePosition)
? Math.max(0, Number(queuePosition || 0)) > 0
? `排队中,前方 ${Math.max(0, Number(queuePosition || 0))} 个`
: "排队中,等待进入执行"
: null;
const queueDetailLine =
phase === "queued"
? `当前模型运行 ${Math.max(
0,
Number(sameModelRunningCount || 0)
)}/${Math.max(1, Number(concurrencyLimit || 0) || 1)},${formatImageQueueWaitLabel(
estimatedWaitMs
)}`
: null;
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-image-loading"
>
<div className="w-full max-w-[560px] space-y-2.5">
<div className="flex items-center gap-2 text-[13px] text-[#9aa3b2]">
<ChevronDown className="h-4 w-4" />
<span className="font-medium">开始生成</span>
</div>
<div className="flex items-center gap-2 text-[#a8b1bf]">
<CircleDot className="h-3.5 w-3.5 fill-current" />
<span className="text-[14px]">{resolvedModelName}</span>
</div>
<div
className={cn(
"relative overflow-hidden rounded-[22px] border border-[#e1e4ea] bg-[#eaedf2]",
count === 1 ? "h-[420px] w-full max-w-[340px]" : "grid gap-3 p-3",
count > 1 && "grid-cols-2"
)}
>
{count === 1 ? (
<>
<div className="absolute inset-0 bg-gradient-to-r from-[#e9ecf1] via-[#dfe4eb] to-[#e9ecf1] opacity-90" />
<div className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.45)_48%,transparent_100%)]" />
</>
) : (
Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="relative h-[180px] overflow-hidden rounded-2xl border border-[#dfe3e9] bg-[#e7ebf0]"
>
<div className="absolute inset-0 bg-gradient-to-r from-[#e7ebf0] via-[#dce2ea] to-[#e7ebf0]" />
<div
className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.42)_48%,transparent_100%)]"
style={{ animationDelay: `${index * 0.2}s` }}
/>
</div>
))
)}
</div>
<div className="flex items-center gap-2 pt-1 text-[13px] text-[#9aa3b2]">
<Grid2x2 className="h-5 w-5" />
<span className="font-medium">{isFailed ? "生成失败" : "设计图处理中"}</span>
</div>
<div className="space-y-0.5 text-[#9aa3b2]">
<div className="text-[13px] font-medium">
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
</div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
{(phase !== "processing" || elapsedSeconds > 0) && (
<div className="text-[13px]">
{PHASE_HINT[phase]} {elapsedLabel}
</div>
)}
</div>
</div>
<style jsx>{`
.generation-skeleton-shimmer {
transform: translateX(-100%);
animation: generation-shimmer 1.8s ease-in-out infinite;
}
@keyframes generation-shimmer {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(110%);
}
}
`}</style>
</div>
);
}
);
ImageGenerationLoading.displayName = "ImageGenerationLoading";