ai-chatbot/components/image-generation-loading.tsx
2026-06-24 10:58:41 +08:00

210 lines
7.5 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, 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 图片",
"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_PROCESSING_HINTS = [
"正在读取参考图与文字需求。",
"正在分析主体、场景和构图关系。",
"正在提取画面中的关键视觉特征。",
"正在确认图片比例和输出尺寸。",
"正在匹配当前模型的最佳生成参数。",
"正在把参考图信息提交给模型。",
"正在等待模型完成第一阶段推理。",
"正在生成初始画面结构。",
"正在保持主体特征和参考一致。",
"正在补全画面中的空间层次。",
"正在调整光影方向和明暗关系。",
"正在优化主体边缘与背景融合。",
"正在细化材质、纹理和局部细节。",
"正在减少画面噪点和不自然区域。",
"正在提升清晰度与整体质感。",
"正在检查生成结果的完整性。",
"正在处理高分辨率输出。",
"正在等待服务商返回最终图片。",
"正在保存图片并同步到对话。",
"正在准备把结果显示到画布。",
];
const LONG_WAIT_SECONDS = 5 * 60;
const LONG_WAIT_HINT =
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT;
const index = Math.min(
LONG_PROCESSING_HINTS.length - 1,
Math.floor(Math.max(0, elapsedSeconds) / 15)
);
return LONG_PROCESSING_HINTS[index];
}
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 estimateRangeLabel = isLongWait
? "单次最长约10分钟"
: providerType === "grsai-gpt-image"
? "2~5分钟"
: "60~120秒";
const elapsedLabel =
resolvedElapsedSeconds > 0
? `已等待 ${resolvedElapsedSeconds}s`
: `预计 ${estimateRangeLabel}`;
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
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 ? "服务繁忙,排队处理中" : undefined}
/>
<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" || resolvedElapsedSeconds > 0) && (
<div className="text-[13px]">
{phaseHint} {elapsedLabel}
</div>
)}
</div>
</div>
</div>
);
}
);
ImageGenerationLoading.displayName = "ImageGenerationLoading";