feat: 在聊天对话中为图片添加变清晰和抠图功能
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

- 在图片预览组件左上角添加变清晰和抠图按钮
- 创建图片处理加载组件(类似Gemini生图的动态卡片效果)
- 实现点击按钮后的处理逻辑:创建临时消息、调用API、更新消息
- 添加消息保存API用于保存处理后的图片消息
- 当消息文本为'正在变清晰...'或'正在抠图...'时显示动态加载组件
- 处理完成后替换加载卡片为处理后的图片并保存到数据库
This commit is contained in:
Your Name 2025-12-29 09:10:52 +08:00
parent 5e0b0d41db
commit 1c7bd9e2d0
7 changed files with 509 additions and 10 deletions

55
app/api/messages/route.ts Normal file
View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { saveMessages } from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
/**
* 保存消息到数据库的API
* 用于图片处理等场景中保存助手回复消息
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { chatId, message } = body;
if (!chatId || !message) {
return NextResponse.json(
{ error: "Missing required fields: chatId and message" },
{ status: 400 }
);
}
// 构造DBMessage对象
const dbMessage: DBMessage = {
id: message.id,
chatId,
role: message.role || "assistant",
parts: message.parts || [],
attachments: message.attachments || [],
createdAt: message.createdAt ? new Date(message.createdAt) : new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
};
// 保存消息到数据库
await saveMessages({ messages: [dbMessage] });
return NextResponse.json({ success: true });
} catch (error) {
console.error("[Messages API] Error:", error);
return NextResponse.json(
{ error: "Failed to save message" },
{ status: 500 }
);
}
}

View File

@ -32,7 +32,7 @@ import { useDataStream } from "@/components/data-stream-provider";
import { Messages } from "@/components/messages";
import { MultimodalInput } from "@/components/multimodal-input";
import { getChatHistoryPaginationKey } from "@/components/sidebar-history";
import { toast } from "@/components/toast";
import { toast } from "sonner";
import type { VisibilityType } from "@/components/visibility-selector";
import type { VideoGenerationParams } from "@/components/video-params-bar";
import type { ImageGenerationParams } from "@/components/image-params-bar";
@ -317,10 +317,7 @@ export function Chat({
) {
setShowCreditCardAlert(true);
} else {
toast({
type: "error",
description: error.message,
});
toast.error(error.message);
}
}
},
@ -448,6 +445,214 @@ export function Chat({
setInput((prev) => prev ? prev : "请根据我的标注修改这张图片:");
};
// 处理图片变清晰
const handleImageEnhance = async (imageUrl: string) => {
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在变清晰..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = async () => {
try {
const base64Data = (reader.result as string).split(",")[1];
// 调用变清晰API
const enhanceResponse = await fetch("/api/enhance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "图片变清晰处理",
}),
});
if (!enhanceResponse.ok) {
const error = await enhanceResponse.json();
throw new Error(error.error || "变清晰处理失败");
}
const result = await enhanceResponse.json();
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
createdAt: new Date().toISOString(),
} as ChatMessage;
// 更新消息
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: finalMessage,
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`变清晰完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}
};
// 处理图片抠图
const handleImageRmbg = async (imageUrl: string) => {
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在抠图..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
try {
// 获取图片并转换为Base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = async () => {
try {
const base64Data = (reader.result as string).split(",")[1];
// 调用抠图API
const rmbgResponse = await fetch("/api/rmbg", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "抠图处理",
}),
});
if (!rmbgResponse.ok) {
const error = await rmbgResponse.json();
throw new Error(error.error || "抠图处理失败");
}
const result = await rmbgResponse.json();
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
createdAt: new Date().toISOString(),
} as ChatMessage;
// 更新消息
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? finalMessage : msg))
);
// 保存消息到数据库
try {
await fetch("/api/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date().toISOString(),
},
}),
});
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
toast.success(`抠图完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
}
};
useAutoResume({
autoResume,
initialMessages,
@ -477,6 +682,8 @@ export function Chat({
votes={votes}
onImageEdit={handleImageEdit}
onDrawingComplete={handleDrawingComplete}
onImageEnhance={handleImageEnhance}
onImageRmbg={handleImageRmbg}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
/>

View File

@ -9,10 +9,12 @@ import { VideoPreview } from "@/components/video-preview";
type ResponseProps = ComponentProps<typeof Streamdown> & {
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
};
export const Response = memo(
({ className, onImageEdit, onDrawingComplete, ...props }: ResponseProps) => (
({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, ...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",
@ -23,7 +25,14 @@ export const Response = memo(
if (!src) {
return null;
}
return <ImagePreview src={src} alt={alt} onImageEdit={onImageEdit} onDrawingComplete={onDrawingComplete} />;
return <ImagePreview
src={src}
alt={alt}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
/>;
},
video: ({ src, alt }: { src?: string; alt?: string }) => {
if (!src) {
@ -36,7 +45,11 @@ export const Response = memo(
/>
),
(prevProps: ResponseProps, nextProps: ResponseProps) =>
prevProps.children === nextProps.children && prevProps.onImageEdit === nextProps.onImageEdit && prevProps.onDrawingComplete === nextProps.onDrawingComplete
prevProps.children === nextProps.children &&
prevProps.onImageEdit === nextProps.onImageEdit &&
prevProps.onDrawingComplete === nextProps.onDrawingComplete &&
prevProps.onImageEnhance === nextProps.onImageEnhance &&
prevProps.onImageRmbg === nextProps.onImageRmbg
);
Response.displayName = "Response";

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState } from "react";
import { X, Download, Edit, Target, Paintbrush } from "lucide-react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors } from "lucide-react";
import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator";
@ -11,6 +11,8 @@ export function ImagePreview({
onImageEdit,
onAnnotate,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
imageIndex
}: {
src: string;
@ -22,6 +24,8 @@ export function ImagePreview({
formattedText: string;
}) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
imageIndex?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
@ -199,6 +203,40 @@ export function ImagePreview({
onClick={() => setIsOpen(true)}
/>
{/* 左上角:变清晰和抠图按钮 */}
{(onImageEnhance || onImageRmbg) && (
<div className="absolute top-2 left-2 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 z-10">
{onImageEnhance && (
<button
className="rounded-full bg-blue-600/90 hover:bg-blue-700 p-2 text-white transition-colors shadow-lg"
onClick={(e) => {
e.stopPropagation();
onImageEnhance(original);
}}
type="button"
aria-label="变清晰"
title="变清晰"
>
<Sparkles size={16} />
</button>
)}
{onImageRmbg && (
<button
className="rounded-full bg-green-600/90 hover:bg-green-700 p-2 text-white transition-colors shadow-lg"
onClick={(e) => {
e.stopPropagation();
onImageRmbg(original);
}}
type="button"
aria-label="抠图"
title="抠图"
>
<Scissors size={16} />
</button>
)}
</div>
)}
{/* 悬停时显示的操作按钮 */}
<div className="absolute bottom-2 right-2 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* 画图按钮 */}

View File

@ -0,0 +1,158 @@
"use client";
import { memo } from "react";
import { SparklesIcon } from "./icons";
import { Scissors } from "lucide-react";
interface ImageProcessingLoadingProps {
type: "enhance" | "rmbg"; // 处理类型:变清晰或抠图
}
/**
* 图片处理加载占位符组件
* 显示类似 Gemini 生图的动态卡片效果
*/
export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProps) => {
const isEnhance = type === "enhance";
const color = isEnhance ? "blue" : "green";
const text = isEnhance ? "正在变清晰" : "正在抠图";
const icon = isEnhance ? SparklesIcon : Scissors;
const Icon = icon;
// 根据类型设置颜色类名
const colorClasses = isEnhance
? {
ping: "bg-blue-500/20",
pulse: "bg-blue-500/10",
ring: "ring-blue-500/30",
text: "text-blue-500",
dot: "bg-blue-500",
gradientFrom: "from-blue-400",
gradientFromOverlay: "from-blue-500/40",
gradientToOverlay: "to-blue-500/30",
}
: {
ping: "bg-green-500/20",
pulse: "bg-green-500/10",
ring: "ring-green-500/30",
text: "text-green-500",
dot: "bg-green-500",
gradientFrom: "from-green-400",
gradientFromOverlay: "from-green-500/40",
gradientToOverlay: "to-green-500/30",
};
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-image-processing-loading"
>
<div className="flex items-start justify-start gap-3">
{/* 头像 */}
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
<div className={`absolute inset-0 rounded-full ${colorClasses.ping} animate-ping`} />
<div className={`absolute inset-0 rounded-full ${colorClasses.pulse} animate-pulse`} />
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${colorClasses.ring}`}>
<div className={`animate-spin ${colorClasses.text}`}>
<Icon size={14} />
</div>
</div>
</div>
<div className="flex flex-col gap-3">
{/* 文本提示 */}
<div className="flex items-center gap-1.5">
<span className="text-base font-medium text-foreground">
{text}
</span>
<span className="flex gap-1">
<span
className={`h-2 w-2 rounded-full ${colorClasses.dot} animate-bounce`}
style={{ animationDelay: "0ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${colorClasses.dot} animate-bounce`}
style={{ animationDelay: "150ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${colorClasses.dot} animate-bounce`}
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
{/* 图片占位符 - 170x200px */}
<div className="relative overflow-hidden rounded-xl" style={{ width: "170px", height: "200px" }}>
{/* 底层渐变背景 */}
<div className={`absolute inset-0 bg-gradient-to-br ${colorClasses.gradientFrom} via-purple-400 to-pink-400 opacity-60`} />
{/* 动态模糊叠加层 1 */}
<div
className={`absolute inset-0 bg-gradient-to-tr ${colorClasses.gradientFromOverlay} via-transparent to-purple-500/40 animate-pulse`}
style={{ animationDuration: "2s" }}
/>
{/* 动态模糊叠加层 2 */}
<div
className={`absolute inset-0 bg-gradient-to-bl from-purple-500/30 via-transparent ${colorClasses.gradientToOverlay} animate-pulse`}
style={{ animationDuration: "2.5s", animationDelay: "0.5s" }}
/>
{/* 噪点纹理效果 */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
}}
/>
{/* 模糊叠加 */}
<div className="absolute inset-0 backdrop-blur-sm" />
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* 外圈光晕 */}
<div className="absolute -inset-4 rounded-full bg-white/20 animate-ping" style={{ animationDuration: "1.5s" }} />
{/* 图标 */}
<Icon
className="h-10 w-10 text-white/80 animate-pulse"
style={{ animationDuration: "1.5s" }}
/>
</div>
</div>
{/* 流光效果 */}
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full"
style={{
animation: "shimmer 2s infinite",
}}
/>
</div>
{/* 提示文字 */}
<span className="text-xs text-muted-foreground">
正在处理图片,请稍候...
</span>
</div>
</div>
{/* 流光动画样式 */}
<style jsx>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
`}</style>
</div>
);
});
ImageProcessingLoading.displayName = "ImageProcessingLoading";

View File

@ -23,6 +23,7 @@ import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
import { ImageProcessingLoading } from "./image-processing-loading";
/**
* 解析文本中的标注内容,返回文本片段数组
@ -147,6 +148,8 @@ const PurePreviewMessage = ({
requiresScrollPadding: _requiresScrollPadding,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
}: {
chatId: string;
message: ChatMessage;
@ -159,6 +162,8 @@ const PurePreviewMessage = ({
requiresScrollPadding: boolean;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
@ -235,6 +240,17 @@ const PurePreviewMessage = ({
if (type === "text") {
if (mode === "view") {
// 检查是否是图片处理的加载消息
if (part.text === "正在变清晰..." || part.text === "正在抠图...") {
const isEnhance = part.text === "正在变清晰...";
return (
<ImageProcessingLoading
key={key}
type={isEnhance ? "enhance" : "rmbg"}
/>
);
}
// 应用视频内容过滤
const filteredText = filterVideoContent(part.text);
const textParts = parseAnnotations(filteredText);
@ -267,7 +283,13 @@ const PurePreviewMessage = ({
);
}
return (
<Response key={partIndex} onImageEdit={onImageEdit} onDrawingComplete={onDrawingComplete}>
<Response
key={partIndex}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
>
{sanitizeText(part.content)}
</Response>
);

View File

@ -37,6 +37,8 @@ type MessagesProps = {
selectedModelId: string;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
};
@ -53,6 +55,8 @@ function PureMessages({
selectedModelId,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
pendingGeneration,
imageCount = 1,
}: MessagesProps) {
@ -220,6 +224,8 @@ function PureMessages({
}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
/>
))}