211 lines
7.5 KiB
TypeScript
211 lines
7.5 KiB
TypeScript
"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 图片",
|
||
"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";
|