重构生图占位状态机并修复结果展示时序
This commit is contained in:
parent
1fd0db078a
commit
0f1ca3f590
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 />;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user