ai-chatbot/components/image-generation-loading.tsx

172 lines
5.9 KiB
TypeScript
Raw Permalink 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, useEffect, useState } from "react";
import { ChevronDown, CircleDot } from "lucide-react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
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 图片",
"snapgen-image": "SnapGen 图片",
"openai-image": "OpenAI 图片",
bananaimage: "BananaImage 图片",
visionary: "Visionary 图片",
atlascloud: "Atlas Cloud 图片",
vip98k: "VIP98K 图片",
"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: "本次处理已停止,请查看对话中的原因和建议。",
};
const LONG_WAIT_SECONDS = 5 * 60;
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,
queuePosition,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs,
}: ImageGenerationLoadingProps) => {
const shouldTrackElapsed =
phase === "queued" || phase === "processing" || phase === "finalizing";
const [internalElapsedSeconds, setInternalElapsedSeconds] = useState(0);
useEffect(() => {
if (typeof elapsedSeconds === "number") return;
if (!shouldTrackElapsed) {
setInternalElapsedSeconds(0);
return;
}
const startedAt = Date.now();
setInternalElapsedSeconds(0);
const timer = window.setInterval(() => {
setInternalElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => window.clearInterval(timer);
}, [elapsedSeconds, modelName, phase, providerType, shouldTrackElapsed]);
const count = Math.min(Math.max(imageCount, 1), maxImages);
const resolvedModelName =
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
const resolvedElapsedSeconds =
typeof elapsedSeconds === "number" ? elapsedSeconds : internalElapsedSeconds;
const isLongWait =
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
(phase === "processing" || phase === "finalizing");
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
const phaseHint = PHASE_HINT[phase];
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-[520px] 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>
<GenerationDotPlaceholder
className={
count === 1
? "h-[336px] min-h-[336px] w-full max-w-[336px]"
: "h-[288px] min-h-[288px] w-full"
}
compact={count > 1}
phase={phase}
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined}
/>
<div className="space-y-0.5 text-[#9aa3b2]">
<div className="text-[13px] font-medium">
使用 {resolvedModelName} 生成图片
</div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
{(phase !== "processing" || resolvedElapsedSeconds > 0) && (
<div className="text-[13px]">
{phaseHint} {elapsedLabel}
</div>
)}
</div>
</div>
</div>
);
}
);
ImageGenerationLoading.displayName = "ImageGenerationLoading";