feat: 图像拓展功能优化和防重复点击

- 图像拓展API改为使用URL方式,避免Base64长度限制(61440字节)
- 先将图片上传到S3获取公网URL,再调用Dashscope API
- 添加防重复点击功能,处理中时禁用所有图片处理按钮
- 添加自动滚动功能,点击处理按钮后自动滚动到最新消息
- 处理完成后也会自动滚动到底部显示结果
This commit is contained in:
Your Name 2025-12-29 16:18:46 +08:00
parent 7526a0159c
commit 1f213bb92d
6 changed files with 217 additions and 16 deletions

View File

@ -85,26 +85,61 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jp
export const maxDuration = 180;
/**
* 图像拓展核心实现
* @param imageBase64 - Base64编码的图片数据
* 将图片上传到S3并返回公网URL(用于API调用)
*/
async function uploadImageToS3ForAPI(imageBase64: string, mimeType: string = "image/jpeg"): Promise<string> {
try {
// 解析 base64 数据
const buffer = Buffer.from(imageBase64, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "jpg";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const filename = `extend-input-${timestamp}-${randomStr}.${ext}`;
console.log(`[Extend API] 上传原图到S3,大小: ${(buffer.length / 1024).toFixed(2)}KB`);
// 转换为 ArrayBuffer
const arrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 上传到 S3
const result = await uploadFile(filename, arrayBuffer);
console.log(`[Extend API] ✅ 原图已上传到S3: ${result.url}`);
return result.url;
} catch (error) {
console.error("[Extend API] 上传到S3失败:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 图像拓展核心实现(使用URL方式,避免Base64长度限制)
* @param imageUrl - 图片的公网URL
* @param apiKey - Dashscope API Key
* @param xScale - X轴放大倍数(默认1.5)
* @param yScale - Y轴放大倍数(默认1.5)
* @returns 拓展后的图片URL
*/
async function extendImage(
imageBase64: string,
imageUrl: string,
apiKey: string,
xScale: number = 1.5,
yScale: number = 1.5
): Promise<string> {
console.log("[Extend API] 开始图像拓展...");
console.log("[Extend API] 使用图片URL:", imageUrl);
// 1. 提交拓展任务
// 1. 提交拓展任务(使用URL方式,避免Base64长度限制)
const requestBody = {
model: "image-out-painting",
input: {
image_base64: imageBase64,
image_url: imageUrl,
},
parameters: {
x_scale: xScale,
@ -187,6 +222,7 @@ async function extendImage(
throw new Error(`图像拓展任务超时(等待${maxAttempts * 2 + 3}秒)`);
}
/**
* 图像拓展API路由
* 从数据库读取图像拓展API配置,调用Dashscope API进行图像拓展处理
@ -286,14 +322,19 @@ export async function POST(request: NextRequest) {
console.log(`[Extend API] 图片Base64长度: ${imageBase64.length} 字符`);
// 3. 调用图像拓展API
// 3. 先将图片上传到S3获取公网URL(使用URL方式避免Base64长度限制)
console.log("[Extend API] 上传原图到S3获取公网URL...");
const inputImageUrl = await uploadImageToS3ForAPI(imageBase64, "image/jpeg");
console.log("[Extend API] 原图URL:", inputImageUrl);
// 4. 调用图像拓展API(使用URL方式)
const startTime = Date.now();
const xScale = config.xScale || 1.5;
const yScale = config.yScale || 1.5;
console.log(`[Extend API] 拓展参数: X=${xScale}, Y=${yScale}`);
const extendedImageUrl = await extendImage(imageBase64, config.apiKey, xScale, yScale);
const extendedImageUrl = await extendImage(inputImageUrl, config.apiKey, xScale, yScale);
console.log("[Extend API] 拓展后图片URL:", extendedImageUrl);
// 4. 下载拓展后的图片
@ -350,3 +391,4 @@ export async function POST(request: NextRequest) {
}
}

View File

@ -111,6 +111,10 @@ export function Chat({
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
// 图片处理状态,防止重复点击
const [isProcessingEnhance, setIsProcessingEnhance] = useState(false);
const [isProcessingRmbg, setIsProcessingRmbg] = useState(false);
const [isProcessingExtend, setIsProcessingExtend] = useState(false);
const currentModelIdRef = useRef(currentModelId);
// 🆕 视频参数状态
@ -502,6 +506,13 @@ export function Chat({
// 处理图片变清晰
const handleImageEnhance = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingEnhance(true);
const messageId = generateUUID();
// 创建临时加载消息
@ -514,6 +525,17 @@ export function Chat({
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
@ -562,6 +584,17 @@ export function Chat({
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
@ -593,12 +626,15 @@ export function Chat({
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
setIsProcessingEnhance(false);
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingEnhance(false);
};
reader.readAsDataURL(blob);
@ -606,11 +642,19 @@ export function Chat({
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingEnhance(false);
}
};
// 处理图片抠图
const handleImageRmbg = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingRmbg(true);
const messageId = generateUUID();
// 创建临时加载消息
@ -623,6 +667,17 @@ export function Chat({
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
@ -671,6 +726,17 @@ export function Chat({
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
@ -702,12 +768,15 @@ export function Chat({
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
setIsProcessingRmbg(false);
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingRmbg(false);
};
reader.readAsDataURL(blob);
@ -715,11 +784,19 @@ export function Chat({
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingRmbg(false);
}
};
// 处理图像拓展
const handleImageExtend = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingExtend(true);
const messageId = generateUUID();
// 创建临时加载消息
@ -734,6 +811,17 @@ export function Chat({
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
@ -782,6 +870,17 @@ export function Chat({
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 滚动到底部,显示处理结果
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
// 保存消息到数据库
try {
await fetch("/api/messages", {
@ -813,12 +912,15 @@ export function Chat({
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
setIsProcessingExtend(false);
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingExtend(false);
};
reader.readAsDataURL(blob);
@ -826,6 +928,7 @@ export function Chat({
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
setIsProcessingExtend(false);
}
};
@ -861,6 +964,9 @@ export function Chat({
onImageEnhance={handleImageEnhance}
onImageRmbg={handleImageRmbg}
onImageExtend={handleImageExtend}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
/>

View File

@ -12,10 +12,13 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
};
export const Response = memo(
({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, onImageExtend, ...props }: ResponseProps) => (
({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, onImageExtend, isProcessingEnhance, isProcessingRmbg, isProcessingExtend, ...props }: ResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
@ -34,6 +37,9 @@ export const Response = memo(
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
/>;
},
video: ({ src, alt }: { src?: string; alt?: string }) => {
@ -52,7 +58,10 @@ export const Response = memo(
prevProps.onDrawingComplete === nextProps.onDrawingComplete &&
prevProps.onImageEnhance === nextProps.onImageEnhance &&
prevProps.onImageRmbg === nextProps.onImageRmbg &&
prevProps.onImageExtend === nextProps.onImageExtend
prevProps.onImageExtend === nextProps.onImageExtend &&
prevProps.isProcessingEnhance === nextProps.isProcessingEnhance &&
prevProps.isProcessingRmbg === nextProps.isProcessingRmbg &&
prevProps.isProcessingExtend === nextProps.isProcessingExtend
);
Response.displayName = "Response";

View File

@ -14,6 +14,9 @@ export function ImagePreview({
onImageEnhance,
onImageRmbg,
onImageExtend,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
imageIndex
}: {
src: string;
@ -28,6 +31,9 @@ export function ImagePreview({
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
imageIndex?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
@ -226,11 +232,14 @@ export function ImagePreview({
<div className="absolute top-2 left-2 flex gap-1.5 z-10">
{onImageEnhance && (
<button
className="flex items-center gap-1.5 rounded-full bg-blue-600/90 hover:bg-blue-700 px-2.5 py-1.5 text-white transition-colors shadow-lg"
className="flex items-center gap-1.5 rounded-full bg-blue-600/90 hover:bg-blue-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onImageEnhance(original);
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
onImageEnhance(original);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend}
type="button"
aria-label="变清晰"
title="变清晰"
@ -241,11 +250,14 @@ export function ImagePreview({
)}
{onImageRmbg && (
<button
className="flex items-center gap-1.5 rounded-full bg-purple-600/90 hover:bg-purple-700 px-2.5 py-1.5 text-white transition-colors shadow-lg"
className="flex items-center gap-1.5 rounded-full bg-purple-600/90 hover:bg-purple-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onImageRmbg(original);
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
onImageRmbg(original);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend}
type="button"
aria-label="抠图"
title="抠图"
@ -256,11 +268,14 @@ export function ImagePreview({
)}
{onImageExtend && (
<button
className="flex items-center gap-1.5 rounded-full bg-orange-600/90 hover:bg-orange-700 px-2.5 py-1.5 text-white transition-colors shadow-lg"
className="flex items-center gap-1.5 rounded-full bg-orange-600/90 hover:bg-orange-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onImageExtend(original);
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
onImageExtend(original);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend}
type="button"
aria-label="图像拓展"
title="图像拓展"

View File

@ -151,6 +151,9 @@ const PurePreviewMessage = ({
onImageEnhance,
onImageRmbg,
onImageExtend,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
}: {
chatId: string;
message: ChatMessage;
@ -166,6 +169,9 @@ const PurePreviewMessage = ({
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
@ -299,6 +305,9 @@ const PurePreviewMessage = ({
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
>
{sanitizeText(part.content)}
</Response>

View File

@ -40,6 +40,9 @@ type MessagesProps = {
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
};
@ -59,6 +62,9 @@ function PureMessages({
onImageEnhance,
onImageRmbg,
onImageExtend,
isProcessingEnhance = false,
isProcessingRmbg = false,
isProcessingExtend = false,
pendingGeneration,
imageCount = 1,
}: MessagesProps) {
@ -176,7 +182,7 @@ function PureMessages({
}
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel]);
// 🆕 监听新消息添加,当用户发送带图片的消息时,强制滚动到底部
// 🆕 监听新消息添加,当用户发送带图片的消息或添加加载消息时,强制滚动到底部
const prevMessagesLengthRef = useRef(messages.length);
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
@ -191,6 +197,17 @@ function PureMessages({
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]);
@ -229,6 +246,9 @@ function PureMessages({
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
/>
))}