重构生图占位状态机并修复结果展示时序

This commit is contained in:
Your Name 2026-02-07 21:10:17 +08:00
parent 1fd0db078a
commit 0f1ca3f590
3 changed files with 199 additions and 47 deletions

View File

@ -37,6 +37,8 @@ import type { VisibilityType } from "@/components/visibility-selector";
import type { VideoGenerationParams } from "@/components/video-params-bar";
import type { ImageGenerationParams } from "@/components/image-params-bar";
type ImageGenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed";
function normalizeImageParamsForProvider(
params: ImageGenerationParams,
providerType?: string
@ -71,6 +73,18 @@ function sortMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] {
});
}
function hasImageMarkdown(parts?: Array<any>) {
if (!parts || parts.length === 0) return false;
return parts.some(
(part) =>
part?.type === "text" &&
typeof part.text === "string" &&
(part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image"))
);
}
export function Chat({
id,
initialMessages,
@ -106,6 +120,9 @@ export function Chat({
// 🆕 跟踪是否正在生成图片(用于占位图显示)
const [isGeneratingImageFlag, setIsGeneratingImageFlag] = useState(false);
const [imageGenerationPhase, setImageGenerationPhase] = useState<ImageGenerationPhase>("idle");
const [imageGenerationStartedAt, setImageGenerationStartedAt] = useState<number | null>(null);
const imageGenerationPhaseRef = useRef<ImageGenerationPhase>("idle");
const imagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突
@ -135,6 +152,20 @@ export function Chat({
}
}, [pendingGeneration, initialMessages, completeGeneration, router]);
const updateImageGenerationPhase = useCallback((nextPhase: ImageGenerationPhase) => {
imageGenerationPhaseRef.current = nextPhase;
setImageGenerationPhase(nextPhase);
if (nextPhase === "idle" || nextPhase === "failed") {
setIsGeneratingImageFlag(false);
setImageGenerationStartedAt(null);
return;
}
setIsGeneratingImageFlag(true);
setImageGenerationStartedAt((previous) => previous ?? Date.now());
}, []);
// 清理占位图定时器
useEffect(() => {
return () => {
@ -369,12 +400,12 @@ export function Chat({
});
if (hasImageGenEndMarker && isMarkerOnlyMessage) {
console.log("[Chat] 🖼️ Image generation ended, hide placeholder");
console.log("[Chat] 🖼️ Image generation entered finalizing stage");
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
setIsGeneratingImageFlag(false);
updateImageGenerationPhase("finalizing");
return; // 不渲染标记消息
}
@ -384,7 +415,7 @@ export function Chat({
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
setIsGeneratingImageFlag(true);
updateImageGenerationPhase("processing");
return; // 不渲染标记消息
}
@ -405,7 +436,7 @@ export function Chat({
}
imagePlaceholderTimerRef.current = setTimeout(() => {
console.log("[Chat] 🖼️ Now showing placeholder after 3s delay");
setIsGeneratingImageFlag(true);
updateImageGenerationPhase("processing");
imagePlaceholderTimerRef.current = null;
}, 3000);
}
@ -414,13 +445,7 @@ export function Chat({
const messageText = messageData.parts?.[0]?.text || "";
console.log("[Chat] 📝 Message text preview:", messageText.substring(0, 100));
const hasImage = messageData.parts?.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
)
);
const hasImage = hasImageMarkdown(messageData.parts);
console.log("[Chat] 🔍 hasImage check:", hasImage);
if (hasImage) {
@ -429,7 +454,7 @@ export function Chat({
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
setIsGeneratingImageFlag(false);
updateImageGenerationPhase("idle");
}
// 🆕 将消息添加到 appendedMessages 状态,而不是直接修改 useChat 的 messages
@ -452,6 +477,21 @@ export function Chat({
console.log("[Chat] Adding to appendedMessages:", messageData.id);
return [...prev, newMessage];
});
const messageTextForErrorCheck = messageData.parts
?.filter((part: any) => part.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
const isLikelyGenerationError =
!hasImage &&
!!messageTextForErrorCheck &&
/失败|超时|重试|error|failed/i.test(messageTextForErrorCheck) &&
imageGenerationPhaseRef.current !== "idle";
if (isLikelyGenerationError) {
console.log("[Chat] ❌ Generation error detected, reset loading state");
updateImageGenerationPhase("failed");
}
// 🆕 收到消息,清除持久化状态
completeGeneration();
@ -469,39 +509,68 @@ export function Chat({
// 🆕 从数据库重新加载消息,确保 data-appendMessage 的消息正确显示
// 这是根治方案 B:后端已保存消息到数据库,前端从数据库重新加载
try {
// 延迟一小段时间确保数据库写入完成
await new Promise(resolve => setTimeout(resolve, 500));
// 获取最新消息
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (response.ok) {
const data = await response.json();
if (data.messages && Array.isArray(data.messages)) {
console.log("[Chat] Reloading messages from database:", data.messages.length);
// 合并消息,避免数据库短暂延迟导致实时 append 的图片消息被覆盖丢失
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
(data.messages as ChatMessage[]).forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {
const mergedMap = new Map<string, ChatMessage>();
prev.forEach((message) => mergedMap.set(message.id, message));
dbMessages.forEach((message) => mergedMap.set(message.id, message));
return sortMessagesByCreatedAt(Array.from(mergedMap.values()));
});
// 仅清理已经成功落库的 appended 消息,保留仍未在数据库中出现的实时消息
const persistedIds = new Set<string>(
(data.messages as ChatMessage[]).map((message) => message.id)
);
setAppendedMessages((prev) =>
prev.filter((message) => !persistedIds.has(message.id))
);
const persistedIds = new Set<string>(dbMessages.map((message) => message.id));
setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id)));
};
const reloadMessagesOnce = async () => {
const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return { hasImage: false };
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages)) {
return { hasImage: false };
}
const dbMessages = data.messages as ChatMessage[];
console.log("[Chat] Reloading messages from database:", dbMessages.length);
mergeMessagesFromDatabase(dbMessages);
const hasPersistedImage = dbMessages.some((message) => hasImageMarkdown((message as any).parts));
return { hasImage: hasPersistedImage };
};
// 延迟一小段时间确保数据库写入完成
await new Promise((resolve) => setTimeout(resolve, 500));
let { hasImage } = await reloadMessagesOnce();
if (!hasImage && imageGenerationPhaseRef.current === "finalizing") {
for (let attempt = 1; attempt <= 6; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 600));
const result = await reloadMessagesOnce();
hasImage = result.hasImage;
if (hasImage) break;
}
}
if (
hasImage ||
(imageGenerationPhaseRef.current !== "idle" &&
imageGenerationPhaseRef.current !== "failed")
) {
updateImageGenerationPhase("idle");
}
} catch (error) {
console.error("[Chat] Failed to reload messages:", error);
}
},
onError: (error) => {
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
updateImageGenerationPhase("failed");
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
@ -551,8 +620,12 @@ export function Chat({
console.log(`[Chat] 🚀 记录生成任务: modelType=${modelType}, modelId=${currentModelId}`);
startGeneration(currentModelId, modelType);
if (modelType === "gemini" || modelType === "volcano-vision" || modelType === "alibaba-image") {
updateImageGenerationPhase("queued");
}
}
}, [status, currentModelId, startGeneration]);
}, [status, currentModelId, startGeneration, updateImageGenerationPhase]);
const searchParams = useSearchParams();
const query = searchParams.get("query");
@ -1428,6 +1501,8 @@ export function Chat({
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
isGeneratingImageFlag={isGeneratingImageFlag}
imageGenerationPhase={imageGenerationPhase}
imageGenerationStartedAt={imageGenerationStartedAt}
/>
<div className="sticky bottom-0 z-1 mx-auto w-full max-w-4xl bg-background px-2 pb-3 md:px-4 md:pb-4">

View File

@ -85,6 +85,8 @@ interface ImageGenerationLoadingProps {
colorTheme?: ColorTheme; // 颜色主题,默认 purple
modelName?: string; // 模型名称,用于显示
providerType?: string; // 提供商类型,用于自动选择颜色
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
elapsedSeconds?: number;
}
/**
@ -97,6 +99,8 @@ export const ImageGenerationLoading = memo(({
colorTheme,
modelName,
providerType,
phase = "processing",
elapsedSeconds = 0,
}: ImageGenerationLoadingProps) => {
// 根据 providerType 自动选择颜色主题
const getAutoColorTheme = (): ColorTheme => {
@ -124,6 +128,20 @@ export const ImageGenerationLoading = memo(({
const displayName = modelName || (providerType === "alibaba-image" ? "千问图片编辑" :
providerType === "ruxa-image" ? "RUXA 图片编辑" :
providerType === "volcano-vision" ? "即梦4.5" : "AI");
const phaseTitle =
phase === "queued"
? "正在排队准备生成"
: phase === "finalizing"
? "图片生成完成,正在加载结果"
: "正在生成图片";
const phaseHint =
phase === "queued"
? `${displayName} 正在排队,请稍候...`
: phase === "finalizing"
? `${displayName} 正在同步结果到聊天记录...`
: `${displayName} 正在创作 ${count} 张图片,请稍候...`;
const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds} 秒` : null;
return (
<div
@ -147,7 +165,7 @@ export const ImageGenerationLoading = memo(({
{/* 文本提示 */}
<div className="flex items-center gap-1.5">
<span className="text-base font-medium text-foreground">
正在生成 {count} 张图片
{phaseTitle}
</span>
<span className="flex gap-1">
<span
@ -235,9 +253,10 @@ export const ImageGenerationLoading = memo(({
</div>
{/* 提示文字 */}
<span className="text-xs text-muted-foreground">
{displayName} 正在创作 {count} 张图片,请稍候...
</span>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{phaseHint}</span>
{elapsedLabel ? <span className="text-foreground/80">{elapsedLabel}</span> : null}
</div>
</div>
</div>

View File

@ -51,6 +51,8 @@ type MessagesProps = {
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
isGeneratingImageFlag?: boolean; // 🆕 正在生成图片的标志(来自 Chat 组件)
imageGenerationPhase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
imageGenerationStartedAt?: number | null;
};
function PureMessages({
@ -78,6 +80,8 @@ function PureMessages({
pendingGeneration,
imageCount = 1,
isGeneratingImageFlag = false,
imageGenerationPhase = "idle",
imageGenerationStartedAt = null,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
@ -120,6 +124,9 @@ function PureMessages({
const currentModelDisplayName = modelsData?.models?.find(
(m: DynamicModel) => m.id === selectedModelId
)?.displayName;
const currentModelProviderType = modelsData?.models?.find(
(m: DynamicModel) => m.id === selectedModelId
)?.providerType;
// 判断是否是视频生成模型(通过 providerType 或模型名称)
const isVideoModel = modelsData?.models?.some(
@ -135,6 +142,11 @@ function PureMessages({
const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false);
const [autoImagePlaceholderReady, setAutoImagePlaceholderReady] = useState(false);
const autoImagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [imageGenerationElapsedSeconds, setImageGenerationElapsedSeconds] = useState(0);
const isImageGenerationActive =
imageGenerationPhase === "queued" ||
imageGenerationPhase === "processing" ||
imageGenerationPhase === "finalizing";
useEffect(() => {
return () => {
@ -144,6 +156,23 @@ function PureMessages({
}
};
}, []);
useEffect(() => {
if (!isImageGenerationActive || !imageGenerationStartedAt) {
setImageGenerationElapsedSeconds(0);
return;
}
const updateElapsed = () => {
setImageGenerationElapsedSeconds(
Math.max(0, Math.floor((Date.now() - imageGenerationStartedAt) / 1000))
);
};
updateElapsed();
const timer = setInterval(updateElapsed, 1000);
return () => clearInterval(timer);
}, [isImageGenerationActive, imageGenerationStartedAt]);
useEffect(() => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
@ -523,8 +552,8 @@ function PureMessages({
isGeneratingImageFlag,
});
// 显示条件:正常等待动画 或 恢复的待处理状态
const shouldShow = showThinkingWithDelay || hasRestoredPending;
// 显示条件:分阶段图片生成状态 / 正常等待动画 / 恢复的待处理状态
const shouldShow = isImageGenerationActive || showThinkingWithDelay || hasRestoredPending;
if (!shouldShow) {
console.log("[Messages] 不显示占位图: shouldShow=false");
@ -573,14 +602,43 @@ function PureMessages({
if (showVideo) return <QianwenVideoLoading />;
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
if (showAlibabaImage) return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" modelName={currentModelDisplayName} />;
if (showAlibabaImage) {
return (
<ImageGenerationLoading
imageCount={imageCount}
maxImages={6}
providerType={
currentModelProviderType === "ruxa-image"
? "ruxa-image"
: "alibaba-image"
}
modelName={currentModelDisplayName}
phase={imageGenerationPhase}
elapsedSeconds={imageGenerationElapsedSeconds}
/>
);
}
// 🆕 智能自动模式占位图
if (isAutoMode && userMessageHasImage) {
// 🆕 只用 isGeneratingImageFlag 来控制是否显示生图占位图
// isGeneratingImageFlag 由 chat.tsx 控制:延迟 3 秒后设置为 true,收到图片后设置为 false
if (autoImagePlaceholderReady && isGeneratingImageFlag && hasAssistantTextReplyWithContent && !hasGenerationEnd) {
return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" modelName="智能处理" />;
if (
autoImagePlaceholderReady &&
isGeneratingImageFlag &&
hasAssistantTextReplyWithContent &&
!hasGenerationEnd
) {
return (
<ImageGenerationLoading
imageCount={imageCount}
maxImages={6}
providerType="alibaba-image"
modelName="智能处理"
phase={imageGenerationPhase}
elapsedSeconds={imageGenerationElapsedSeconds}
/>
);
}
// 否则显示思考动画(在等待 AI 分析或延迟期间)
return <ThinkingMessage />;