ai-chatbot/components/messages.tsx
Your Name 3737b1ce99
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: 修复占位图逻辑和移除导致错误的刷新
1. 移除 onFinish 中的 router.refresh(),它导致 React 错误
2. 简化占位图逻辑:hasImageContent 优先级最高
3. 当有图片内容时,无论其他标记如何,都隐藏占位图

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-01 10:20:59 +08:00

497 lines
20 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.

import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect, useState, useRef } from "react";
import useSWR from "swr";
import { useMessages } from "@/hooks/use-messages";
import type { PendingGeneration } from "@/hooks/use-pending-generation";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { GeminiImageLoading } from "./gemini-image-loading";
import { QianwenVideoLoading } from "./qianwen-video-loading";
import { VolcanoVisionLoading } from "./volcano-vision-loading";
import { ImageGenerationLoading } from "./image-generation-loading";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
// 模型数据类型
type DynamicModel = {
id: string;
providerType: string;
modelId: string;
displayName: string;
};
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type MessagesProps = {
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
isGeneratingImageFlag?: boolean; // 🆕 正在生成图片的标志(来自 Chat 组件)
};
function PureMessages({
chatId,
status,
votes,
messages,
setMessages,
regenerate,
sendMessage,
isReadonly,
selectedModelId,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
onImageAngleEdit,
isProcessingEnhance = false,
isProcessingRmbg = false,
isProcessingExtend = false,
isProcessingAngleEdit = false,
pendingGeneration,
imageCount = 1,
isGeneratingImageFlag = false,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
} = useMessages({
status,
});
useDataStream();
// 获取模型列表以判断当前模型类型
const { data: modelsData } = useSWR<{ models: DynamicModel[] }>(
"/api/models",
fetcher
);
// 判断当前选中的模型类型
const isGeminiModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "gemini"
) ?? false;
const isQianwenVideoModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "qianwen-video"
) ?? false;
const isVolcanoVisionModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision"
) ?? false;
// 🆕 判断是否是阿里生图模型(或者智能自动模式默认使用阿里生图占位图)
const isAlibabaImageModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "alibaba-image"
) ?? false;
// 🆕 获取当前模型的显示名称
const currentModelDisplayName = modelsData?.models?.find(
(m: DynamicModel) => m.id === selectedModelId
)?.displayName;
// 判断是否是视频生成模型(通过 providerType 或模型名称)
const isVideoModel = modelsData?.models?.some(
(m: DynamicModel) =>
m.id === selectedModelId &&
(m.providerType === "volcano-video" ||
m.modelId?.toLowerCase().includes("video") ||
m.modelId?.toLowerCase().includes("sora") ||
m.displayName?.includes("视频"))
) ?? false;
// 控制等待动画延迟消失(Gemini 图片模型延迟 4 秒,其他模型 6 秒)
const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false);
useEffect(() => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
// 对于视频模型,检查是否有真正的视频内容(包含 <video 标签或 [视频] 标记)
const hasVideoContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("[视频]") ||
part.text.includes("<video") ||
part.text.includes("视频生成完成")
)
);
// 调试日志
if (isVideoModel) {
const firstPart = lastMessage?.parts?.[0];
const messageText = firstPart && 'text' in firstPart ? firstPart.text?.substring(0, 100) : undefined;
console.log("[占位图调试] 状态:", {
status,
isVideoModel,
hasVideoContent,
messageRole: lastMessage?.role,
messageText
});
}
// 🆕 智能自动模式:检查用户最后一条消息是否包含图片,如果有则视为图片编辑场景
// 注意:需要找用户的最后一条消息,而不是整个对话的最后一条
const lastUserMessage = [...messages].reverse().find(m => m.role === "user");
const isAutoModeWithImage = selectedModelId === "auto" &&
lastUserMessage?.parts?.some((p: any) => p.type === "file" || p.type === "image");
// 🆕 检查是否有"图片正在生成"的标记(表示对话消息已发送,正在等待图片生成)
const isGeneratingImage = messages.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- GENERATING_IMAGE -->")
)
);
// 对于图片模型,检查是否有图片内容(包含 ![thumb| 或 ![thumb||| 标记)
// 🆕 在智能自动模式下,需要检查最后一条用户消息之后的所有 assistant 消息
let hasImageContent = false;
if (isAutoModeWithImage) {
// 找到最后一条用户消息的索引
const lastUserIndex = messages.findLastIndex(m => m.role === "user");
// 检查之后所有的 assistant 消息是否包含图片
const assistantMessagesAfterUser = messages.slice(lastUserIndex + 1).filter(m => m.role === "assistant");
hasImageContent = assistantMessagesAfterUser.some(msg =>
msg.parts?.some((part: any) => part.type === "text" && part.text && (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
))
);
} else {
// 普通模式:只检查最后一条消息
hasImageContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
)
) || false;
}
// 对于其他模型,检查是否有实际的文本内容
const hasTextContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && part.text.trim().length > 0
);
// 判断是否是图片生成模型(包括 Gemini、火山、阿里)
const isImageGenModel = isGeminiModel || isVolcanoVisionModel || isAlibabaImageModel;
// 🆕 智能自动模式的特殊处理
// - 收到 GENERATING_IMAGE 标记前:显示普通思考动画
// - 收到 GENERATING_IMAGE 标记后:显示生图占位图
// - 收到图片后:隐藏占位图
let hasContent = false;
if (isQianwenVideoModel || isVideoModel) {
hasContent = hasVideoContent ?? false;
} else if (isImageGenModel) {
hasContent = hasImageContent ?? false;
} else if (isAutoModeWithImage) {
// 智能自动模式:只有图片出现才算有内容
hasContent = hasImageContent ?? false;
} else {
hasContent = hasTextContent ?? false;
}
// 根据模型类型设置延迟时间
const delayTime = (isQianwenVideoModel || isVideoModel) ? 60000 : (isImageGenModel || isAutoModeWithImage) ? 4000 : 6000;
// 🆕 智能自动模式的占位图逻辑(简化版)
if (isAutoModeWithImage) {
console.log("[Messages useEffect] 智能模式检查:", {
hasImageContent,
isGeneratingImage,
isGeneratingImageFlag,
status,
});
// 🆕 简化的占位图逻辑
// 优先级:hasImageContent > isGeneratingImageFlag > isGeneratingImage > status
if (hasImageContent) {
// 图片已生成,延迟后隐藏占位图(无论其他标记如何)
console.log("[Messages useEffect] 图片已生成,延迟后隐藏");
const timer = setTimeout(() => {
setShowThinkingWithDelay(false);
}, delayTime);
return () => clearTimeout(timer);
} else if (isGeneratingImageFlag) {
// 后端正在生成图片
console.log("[Messages useEffect] isGeneratingImageFlag=true,显示占位图");
setShowThinkingWithDelay(true);
} else if (isGeneratingImage) {
// 消息中检测到 GENERATING_IMAGE 标记(但没有图片内容)
console.log("[Messages useEffect] 检测到消息中的生成标记,显示占位图");
setShowThinkingWithDelay(true);
} else if (status === "streaming" || status === "submitted") {
// 正在处理中(分析意图或流式传输)
console.log("[Messages useEffect] streaming/submitted,显示占位图");
setShowThinkingWithDelay(true);
} else {
// ready 或其他状态:不显示占位图
console.log("[Messages useEffect] ready状态,隐藏占位图");
setShowThinkingWithDelay(false);
}
} else {
// 非智能自动模式的原有逻辑
if (status === "submitted") {
setShowThinkingWithDelay(true);
} else if (status === "streaming" && lastMessage?.role === "assistant" && !hasContent) {
setShowThinkingWithDelay(true);
} else if (status === "streaming" && lastMessage?.role === "assistant" && hasContent) {
const timer = setTimeout(() => {
setShowThinkingWithDelay(false);
}, delayTime);
return () => clearTimeout(timer);
} else {
setShowThinkingWithDelay(false);
}
}
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel, selectedModelId, isGeneratingImageFlag]);
// 🆕 监听新消息添加,当用户发送带图片的消息或添加加载消息时,强制滚动到底部
const prevMessagesLengthRef = useRef(messages.length);
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
const lastMessage = messages[messages.length - 1];
// 如果是新添加的用户消息且包含图片附件,强制滚动到底部
if (lastMessage?.role === "user") {
const hasImageAttachment = lastMessage.parts?.some(
(part: any) => part.type === "file" && part.mediaType?.startsWith("image/")
);
if (hasImageAttachment) {
// 延迟一小段时间确保DOM已更新
setTimeout(() => scrollToBottom("smooth"), 100);
}
}
// 如果是新添加的助手消息且是加载消息(正在变清晰/抠图/图像拓展),强制滚动到底部
if (lastMessage?.role === "assistant") {
const firstPart = lastMessage.parts?.[0];
if (firstPart && "text" in firstPart) {
const loadingText = firstPart.text;
if (loadingText === "正在变清晰..." || loadingText === "正在抠图..." || loadingText === "正在图像拓展...") {
// 延迟一小段时间确保DOM已更新
setTimeout(() => scrollToBottom("smooth"), 100);
}
}
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages, scrollToBottom]);
// 🔍 调试:追踪消息数组变化
useEffect(() => {
console.log("[Messages] 📋 消息数组更新:", {
count: messages.length,
messages: messages.map(m => ({
id: m.id?.substring(0, 8),
role: m.role,
partsCount: m.parts?.length,
firstPartType: m.parts?.[0]?.type,
firstPartText: m.parts?.[0]?.type === "text" ? m.parts[0].text?.substring(0, 50) : undefined
}))
});
}, [messages]);
return (
<div className="relative flex-1">
<div
className="absolute inset-0 touch-pan-y overflow-y-auto"
ref={messagesContainerRef}
>
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => {
// 🆕 清理消息中的内部标记(如 <!-- GENERATING_IMAGE -->)
const cleanedMessage = {
...message,
parts: message.parts?.map((part: any) => {
if (part.type === "text" && part.text) {
return {
...part,
text: part.text.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
};
}
return part;
})
};
return (
<PreviewMessage
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={cleanedMessage}
regenerate={regenerate}
sendMessage={sendMessage}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
/>
);
})}
{/* 等待动画:根据模型类型显示不同的加载效果 */}
{/* 🆕 支持恢复的生成状态(用户离开再返回) */}
{(() => {
// 检查最后一条消息是否是用户消息(表示还在等待 AI 响应)
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
const isWaitingForResponse = lastMessage?.role === "user";
// 恢复的待处理状态:有 pendingGeneration 且最后一条是用户消息
const hasRestoredPending = pendingGeneration && isWaitingForResponse;
// 🔍 调试日志
console.log("[Messages] 占位图状态检查:", {
pendingGeneration,
lastMessageRole: lastMessage?.role,
isWaitingForResponse,
hasRestoredPending,
showThinkingWithDelay,
messagesCount: messages.length,
isGeneratingImageFlag, // 🆕 添加这个调试
});
// 显示条件:正常等待动画 或 恢复的待处理状态
const shouldShow = showThinkingWithDelay || hasRestoredPending;
if (!shouldShow) {
console.log("[Messages] 不显示占位图: shouldShow=false");
return null;
}
console.log("[Messages] ✅ 显示占位图!");
// 优先使用恢复的状态,否则使用当前模型类型
const modelType = pendingGeneration?.modelType;
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel);
const showAlibabaImage = modelType === "alibaba-image" || (!modelType && isAlibabaImageModel);
const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel));
// 🆕 智能自动模式:检查用户是否发送了图片
const isAutoMode = selectedModelId === "auto";
const lastUserMsg = [...messages].reverse().find(m => m.role === "user");
const userMessageHasImage = lastUserMsg?.parts?.some((p: any) => p.type === "file" || p.type === "image");
// 🆕 检查是否收到了 GENERATING_IMAGE 标记(表示对话已发送,开始生图)
const hasGeneratingMarker = messages.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- GENERATING_IMAGE -->")
)
);
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 (isAutoMode && userMessageHasImage) {
// 如果已经收到了 GENERATING_IMAGE 标记 或 isGeneratingImageFlag 为 true,显示生图占位图
if (hasGeneratingMarker || isGeneratingImageFlag) {
return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" modelName="智能处理" />;
}
// 否则显示思考动画
return <ThinkingMessage />;
}
// 其他情况显示通用思考动画
return <ThinkingMessage />;
})()}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="滚动至底部"
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? "pointer-events-none scale-0 opacity-0"
: "pointer-events-auto scale-100 opacity-100"
}`}
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
</div>
);
}
export const Messages = memo(PureMessages, (prevProps: MessagesProps, nextProps: MessagesProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
// 🆕 检查持久化生成状态变化
if (!equal(prevProps.pendingGeneration, nextProps.pendingGeneration)) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages)) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return false;
});