ai-chatbot/components/messages.tsx
Your Name 1c7bd9e2d0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 在聊天对话中为图片添加变清晰和抠图功能
- 在图片预览组件左上角添加变清晰和抠图按钮
- 创建图片处理加载组件(类似Gemini生图的动态卡片效果)
- 实现点击按钮后的处理逻辑:创建临时消息、调用API、更新消息
- 添加消息保存API用于保存处理后的图片消息
- 当消息文本为'正在变清晰...'或'正在抠图...'时显示动态加载组件
- 处理完成后替换加载卡片为处理后的图片并保存到数据库
2025-12-29 09:10:52 +08:00

324 lines
12 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 { 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;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
};
function PureMessages({
chatId,
status,
votes,
messages,
setMessages,
regenerate,
sendMessage,
isReadonly,
selectedModelId,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
pendingGeneration,
imageCount = 1,
}: 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;
// 判断是否是视频生成模型(通过 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
});
}
// 对于图片模型,检查是否有图片内容(包含 ![thumb| 标记)
const hasImageContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && part.text.includes("![thumb|")
);
// 对于其他模型,检查是否有实际的文本内容
const hasTextContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && part.text.trim().length > 0
);
// 根据模型类型判断是否有有效内容
let hasContent = false;
if (isQianwenVideoModel || isVideoModel) {
hasContent = hasVideoContent ?? false; // 视频模型:只有真正的视频才算有内容
} else if (isGeminiModel || isVolcanoVisionModel) {
hasContent = hasImageContent ?? false; // 图片模型:只有图片才算有内容
} else {
hasContent = hasTextContent ?? false; // 其他模型:任何文本都算有内容
}
// 根据模型类型设置延迟时间(视频模型 60 秒,图片模型 4 秒,其他 6 秒)
const delayTime = (isQianwenVideoModel || isVideoModel) ? 60000 : (isGeminiModel || isVolcanoVisionModel) ? 4000 : 6000;
// 如果是 submitted 状态,立即显示
if (status === "submitted") {
setShowThinkingWithDelay(true);
}
// 如果是 streaming 状态且没有内容,显示
else if (status === "streaming" && lastMessage?.role === "assistant" && !hasContent) {
setShowThinkingWithDelay(true);
}
// 如果 AI 开始有内容了,延迟后隐藏
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]);
// 🆕 监听新消息添加,当用户发送带图片的消息时,强制滚动到底部
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);
}
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages, scrollToBottom]);
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) => (
<PreviewMessage
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={message}
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}
/>
))}
{/* 等待动画:根据模型类型显示不同的加载效果 */}
{/* 🆕 支持恢复的生成状态(用户离开再返回) */}
{(() => {
// 检查最后一条消息是否是用户消息(表示还在等待 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,
});
// 显示条件:正常等待动画 或 恢复的待处理状态
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 showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel));
if (showVideo) return <QianwenVideoLoading />;
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
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;
});