ai-chatbot/components/image-preview.tsx
Your Name 7c253e04a0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 添加图像拓展功能
- 新增 /api/extend 路由,集成阿里云Dashscope图像拓展API
- 在系统设置'本地AI设置'中添加图像拓展API配置(API密钥、X/Y轴放大倍数)
- 在图片标注组件中添加'图像拓展'按钮(橙色,Expand图标)
- 在聊天对话图片上添加'图像拓展'按钮,支持动态占位卡片
- 更新ImageProcessingLoading组件支持extend类型(橙色主题)
- 处理后的图片上传到S3,保存到数据库(modelId: extend-1.0)
- 同步到我的相册和出图记录
- 添加完整的API调用文档
2025-12-29 10:54:32 +08:00

489 lines
17 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.

"use client";
import React, { useState } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand } from "lucide-react";
import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator";
export function ImagePreview({
src,
alt,
onImageEdit,
onAnnotate,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
imageIndex
}: {
src: string;
alt?: string;
onImageEdit?: (imageUrl: string) => void;
onAnnotate?: (annotation: {
imageIndex: number;
description: string;
formattedText: string;
}) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
imageIndex?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [showAnnotator, setShowAnnotator] = useState(false);
// 解析 alt 文本,提取原图 URL 和宽度
// 格式优先级:
// 1. thumb|||原图URL|||imageSize (最新格式,推荐,不会与 LaTeX/Markdown 冲突)
// 2. thumb$$原图URL$$imageSize (旧格式,会被 LaTeX 解析器干扰)
// 3. thumb|原图URL|imageSize (最旧格式)
const parseImageUrls = () => {
// 调试日志:查看接收到的 alt 和 src
console.log("[ImagePreview] Parsing alt:", alt, "src:", src?.substring(0, 80));
let originalUrl = src;
let maxWidth = 500;
let is4K = false;
let parsed = false;
// 最新格式:使用 ||| 分隔 (不会与 LaTeX 数学公式或 Markdown 表格冲突)
if (alt && alt.includes("thumb|||")) {
const content = alt.substring(alt.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0];
const extraInfo = parts[1];
if (extraInfo === "4K") {
is4K = true;
} else if (extraInfo === "2K") {
// 2K 不是 4K,但也是高质量
is4K = false;
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
maxWidth = parseInt(extraInfo, 10);
}
parsed = true;
console.log("[ImagePreview] Parsed with ||| format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
}
// 旧格式:使用 $$ 分隔 (可能被 LaTeX 干扰,保持向后兼容)
else if (alt && alt.includes("thumb$$")) {
const content = alt.substring(alt.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0];
const extraInfo = parts[1];
if (extraInfo === "4K") {
is4K = true;
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
maxWidth = parseInt(extraInfo, 10);
}
parsed = true;
console.log("[ImagePreview] Parsed with $$ format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
}
// 最旧格式:使用 | 分隔
else if (alt && alt.includes("thumb|")) {
const content = alt.substring(alt.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0];
const extraInfo = parts[1];
if (extraInfo === "4K") {
is4K = true;
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
maxWidth = parseInt(extraInfo, 10);
}
parsed = true;
console.log("[ImagePreview] Parsed with | format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
}
// 🛡️ 兜底策略:如果解析失败,或者解析出的 originalUrl 与 src 相同(意味着没提取到),
// 尝试利用文件命名规律推导原图 URL
if (!parsed || originalUrl === src) {
if (src.includes("gemini-thumb-")) {
originalUrl = src.replace("gemini-thumb-", "gemini-orig-");
console.log("[ImagePreview] Inferred original URL from gemini-thumb filename");
} else if (src.includes("openai-thumb-")) {
originalUrl = src.replace("openai-thumb-", "openai-orig-");
console.log("[ImagePreview] Inferred original URL from openai-thumb filename");
} else if (src.includes("/thumb/") || src.includes("-thumb")) {
// 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx)
originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/");
console.log("[ImagePreview] Inferred original URL from S3 path pattern");
} else {
// 如果无法推断,且src看起来已经是完整的URL(不是缩略图),直接使用src作为原图
// 否则记录警告
if (src.startsWith("http://") || src.startsWith("https://")) {
// 如果src已经是完整URL且不包含thumb标识,可能是原图
if (!src.includes("thumb") && !src.includes("thumbnail")) {
originalUrl = src;
console.log("[ImagePreview] Using src as original URL (no thumb identifier found)");
} else {
console.log("[ImagePreview] ⚠️ Could not parse original URL, using thumbnail as fallback");
}
} else {
console.log("[ImagePreview] ⚠️ Could not parse original URL, using thumbnail as fallback");
}
}
}
return {
thumbnail: src,
original: originalUrl,
maxWidth,
is4K,
};
};
const { thumbnail, original, maxWidth, is4K } = parseImageUrls();
// 下载图片函数(下载原图)
const handleDownload = async () => {
try {
const response = await fetch(original);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `gemini-image-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("下载失败:", error);
}
};
// 处理图片点击(标注模式)
const handleImageClick = async (e: React.MouseEvent<HTMLImageElement>) => {
if (!isAnnotationMode || isDetecting || !onAnnotate) return;
const img = e.currentTarget;
const rect = img.getBoundingClientRect();
const clickX = Math.round(e.clientX - rect.left);
const clickY = Math.round(e.clientY - rect.top);
setIsDetecting(true);
toast.info("正在识别物体...");
try {
const response = await fetch("/api/detect-object", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageUrl: original,
clickX,
clickY,
imageIndex: imageIndex || 1,
imageWidth: img.naturalWidth,
imageHeight: img.naturalHeight,
}),
});
const result = await response.json();
if (result.success && result.data) {
toast.success(`识别成功:${result.data.description}`);
onAnnotate({
imageIndex: result.data.imageIndex,
description: result.data.description,
formattedText: result.data.formattedText,
});
setIsOpen(false);
setIsAnnotationMode(false);
} else {
toast.error(result.error || "识别失败,请重试");
}
} catch (error) {
console.error("物体检测失败:", error);
toast.error("识别失败,请检查网络连接");
} finally {
setIsDetecting(false);
}
};
return (
<>
{/* 缩略图(显示压缩后的缩略图)*/}
<div className="group relative inline-block max-w-full">
<img
src={thumbnail}
alt="Generated Image"
className="cursor-pointer rounded-lg transition-opacity hover:opacity-90"
style={{
maxWidth: `${maxWidth}px`,
height: "auto",
width: "100%",
objectFit: "contain"
}}
onClick={() => setIsOpen(true)}
/>
{/* 左上角:变清晰、抠图和图像拓展按钮 - 始终显示 */}
{(onImageEnhance || onImageRmbg || onImageExtend) && (
<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"
onClick={(e) => {
e.stopPropagation();
onImageEnhance(original);
}}
type="button"
aria-label="变清晰"
title="变清晰"
>
<Sparkles size={14} />
<span className="text-xs font-medium">变清晰</span>
</button>
)}
{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"
onClick={(e) => {
e.stopPropagation();
onImageRmbg(original);
}}
type="button"
aria-label="抠图"
title="抠图"
>
<Scissors size={14} />
<span className="text-xs font-medium">抠图</span>
</button>
)}
{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"
onClick={(e) => {
e.stopPropagation();
onImageExtend(original);
}}
type="button"
aria-label="图像拓展"
title="图像拓展"
>
<Expand size={14} />
<span className="text-xs font-medium">图像拓展</span>
</button>
)}
</div>
)}
{/* 悬停时显示的操作按钮 */}
<div className="absolute bottom-2 right-2 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* 画图按钮 */}
{onDrawingComplete && (
<button
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90"
onClick={(e) => {
e.stopPropagation();
setShowAnnotator(true);
}}
type="button"
aria-label="画图标注"
title="画图标注"
>
<Paintbrush size={16} />
</button>
)}
{/* 编辑按钮 */}
{onImageEdit && (
<button
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90"
onClick={(e) => {
e.stopPropagation();
onImageEdit(original);
}}
type="button"
aria-label="编辑图片"
>
<Edit size={16} />
</button>
)}
{/* 下载按钮 */}
<button
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90 flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
type="button"
aria-label="下载图片"
title={original !== thumbnail ? (is4K ? "下载4K原图" : "下载原图") : "下载图片"}
>
<Download size={16} />
{original !== thumbnail && <span className="text-xs font-medium pr-1">{is4K ? "4K" : "原图"}</span>}
</button>
</div>
</div>
{/* 弹窗 */}
{isOpen && (
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 p-4"
onClick={() => setIsOpen(false)}
>
{/* 按钮组 */}
<div className="absolute right-4 top-4 flex gap-2">
{/* 下载按钮 (新增) */}
<button
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
type="button"
aria-label="下载图片"
title={original !== thumbnail ? (is4K ? "下载4K原图" : "下载原图") : "下载图片"}
>
<Download size={24} />
{original !== thumbnail && <span className="text-sm font-medium pr-1">{is4K ? "下载4K原图" : "下载原图"}</span>}
</button>
{/* 定位标注按钮 */}
{onAnnotate && (
<button
className={`rounded-full p-2 text-white transition-colors ${
isAnnotationMode
? "bg-blue-500 hover:bg-blue-600"
: "bg-white/10 hover:bg-white/20"
}`}
onClick={(e) => {
e.stopPropagation();
setIsAnnotationMode(!isAnnotationMode);
if (!isAnnotationMode) {
toast.info("标注模式:点击图片中的物体进行识别");
}
}}
disabled={isDetecting}
type="button"
aria-label="定位标注"
title="定位标注"
>
<Target size={24} />
</button>
)}
{/* 画图标注按钮 */}
{onDrawingComplete && (
<button
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
setShowAnnotator(true);
setIsOpen(false);
}}
type="button"
aria-label="画图标注"
title="画图标注"
>
<Paintbrush size={24} />
</button>
)}
{/* 编辑按钮 */}
{onImageEdit && (
<button
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
onImageEdit(original);
setIsOpen(false);
}}
type="button"
aria-label="编辑图片"
title="编辑图片"
>
<Edit size={24} />
</button>
)}
{/* 下载按钮 */}
<button
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
type="button"
aria-label="下载图片"
title="下载图片"
>
<Download size={24} />
</button>
{/* 关闭按钮 */}
<button
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
onClick={() => {
setIsOpen(false);
setIsAnnotationMode(false);
}}
type="button"
aria-label="关闭"
title="关闭"
>
<X size={24} />
</button>
</div>
{/* 标注模式提示 */}
{isAnnotationMode && (
<div className="absolute left-1/2 top-4 -translate-x-1/2 transform">
<div className="rounded-full bg-blue-500 px-4 py-2 text-sm text-white shadow-lg">
点击图片中的物体进行标注
</div>
</div>
)}
{/* 大图(显示原图)*/}
<div
className={`relative max-h-[90vh] max-w-[90vw] ${
isDetecting ? "opacity-50" : ""
}`}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<img
src={original}
alt="Generated Image - Original"
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
isAnnotationMode ? "cursor-crosshair" : ""
}`}
style={{ maxWidth: "90vw" }}
onClick={handleImageClick}
/>
{/* 加载状态 */}
{isDetecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center gap-3 rounded-lg bg-black/70 px-6 py-3 text-white">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>正在识别...</span>
</div>
</div>
)}
</div>
</div>
)}
{/* 图片标注器 */}
{showAnnotator && onDrawingComplete && (
<ImageAnnotator
imageUrl={original}
onConfirm={(annotatedImageUrl) => {
onDrawingComplete(annotatedImageUrl);
setShowAnnotator(false);
toast.success("标注图片已添加到聊天");
}}
onCancel={() => setShowAnnotator(false)}
/>
)}
</>
);
}