优化图片预览和标注功能
This commit is contained in:
parent
ee2d5e90ea
commit
4c25d433f8
@ -83,6 +83,27 @@ async function convertImageUrlToBase64(url: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测消息中是否包含用户标注过的图片(文件名以 annotated- 开头)
|
||||
*/
|
||||
function hasAnnotatedImage(messages: ChatMessage[]): boolean {
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "file" && "name" in part && typeof part.name === "string") {
|
||||
if (part.name.startsWith("annotated-")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标注图片的系统提示词(用户不可见,后台自动添加)
|
||||
*/
|
||||
const ANNOTATION_SYSTEM_PROMPT = "【重要】用户提供的图片上有标注线条(如箭头、圆圈、画笔痕迹或文字说明),这些标注是用户用来指示需要修改的区域或方向。请仔细理解标注的含义,按照标注指示进行编辑,但在生成的新图片中不要保留这些标注线条,输出干净的编辑结果。";
|
||||
|
||||
/**
|
||||
* 自定义消息转换函数,支持将图片 URL 转换为 base64(仅用于 Gemini)
|
||||
*/
|
||||
@ -93,18 +114,36 @@ async function convertMessagesWithImages(messages: ChatMessage[], providerType:
|
||||
return convertToModelMessages(messages);
|
||||
}
|
||||
|
||||
// 🆕 检测是否有标注图片
|
||||
const hasAnnotation = hasAnnotatedImage(messages);
|
||||
if (hasAnnotation) {
|
||||
console.log("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
|
||||
}
|
||||
|
||||
// 对于 Gemini,需要特殊处理图片
|
||||
const convertedMessages = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const content: any[] = [];
|
||||
|
||||
// 🆕 如果是用户消息且有标注图片,在文本前添加系统提示
|
||||
let hasAddedAnnotationPrompt = false;
|
||||
|
||||
// 处理消息的各个部分
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
let textContent = part.text;
|
||||
|
||||
// 🆕 如果有标注图片且是用户消息,在用户文本前添加系统提示
|
||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt) {
|
||||
textContent = ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + textContent;
|
||||
hasAddedAnnotationPrompt = true;
|
||||
console.log("[Message Convert] 🎨 已注入标注提示词");
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
text: textContent,
|
||||
});
|
||||
} else if (part.type === "file" && part.url) {
|
||||
// 将图片 URL 转换为 base64
|
||||
@ -130,6 +169,15 @@ async function convertMessagesWithImages(messages: ChatMessage[], providerType:
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 如果有标注图片但消息中没有文本部分,添加一个纯提示文本
|
||||
if (hasAnnotation && message.role === "user" && !hasAddedAnnotationPrompt && content.length > 0) {
|
||||
content.unshift({
|
||||
type: "text",
|
||||
text: ANNOTATION_SYSTEM_PROMPT,
|
||||
});
|
||||
console.log("[Message Convert] 🎨 已在消息开头注入标注提示词");
|
||||
}
|
||||
|
||||
convertedMessages.push({
|
||||
role: message.role,
|
||||
content: content.length === 1 && content[0].type === "text"
|
||||
@ -800,11 +848,36 @@ export async function POST(request: Request) {
|
||||
if ((languageModel as any).provider === "volcano-vision" && typeof (languageModel as any).doGenerate === "function") {
|
||||
console.log("[Chat API] Using Volcano Vision image generation");
|
||||
|
||||
// 🆕 检测是否有标注图片,如果有则在消息中注入系统提示
|
||||
const hasAnnotation = hasAnnotatedImage(uiMessages);
|
||||
let messagesForVolcano = uiMessages;
|
||||
|
||||
if (hasAnnotation) {
|
||||
console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示");
|
||||
// 创建消息副本,修改最后一条用户消息的文本
|
||||
messagesForVolcano = uiMessages.map((msg, idx) => {
|
||||
if (msg.role === "user" && idx === uiMessages.length - 1) {
|
||||
// 修改最后一条用户消息
|
||||
const modifiedParts = msg.parts.map((part) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return {
|
||||
...part,
|
||||
text: ANNOTATION_SYSTEM_PROMPT + "\n\n用户请求:" + part.text,
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return { ...msg, parts: modifiedParts };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
const volcanoStream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
try {
|
||||
// 转换消息,火山引擎支持 URL 和 base64
|
||||
const modelMessages = await convertMessagesWithImages(uiMessages, "volcano");
|
||||
const modelMessages = await convertMessagesWithImages(messagesForVolcano, "volcano");
|
||||
|
||||
// 调用 doGenerate 获取图片
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
|
||||
@ -372,6 +372,24 @@ export function Chat({
|
||||
setInput((prev) => prev ? prev : "请帮我编辑这张图片:");
|
||||
};
|
||||
|
||||
// 处理画图标注完成
|
||||
const handleDrawingComplete = (annotatedImageUrl: string) => {
|
||||
// annotatedImageUrl 是 base64 data URL 格式
|
||||
const fileName = `annotated-${Date.now()}.png`;
|
||||
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: annotatedImageUrl,
|
||||
name: fileName,
|
||||
contentType: "image/png",
|
||||
},
|
||||
]);
|
||||
|
||||
// 如果输入框为空,自动填入提示词
|
||||
setInput((prev) => prev ? prev : "请根据我的标注修改这张图片:");
|
||||
};
|
||||
|
||||
useAutoResume({
|
||||
autoResume,
|
||||
initialMessages,
|
||||
@ -400,6 +418,7 @@ export function Chat({
|
||||
status={status}
|
||||
votes={votes}
|
||||
onImageEdit={handleImageEdit}
|
||||
onDrawingComplete={handleDrawingComplete}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-1 mx-auto w-full max-w-4xl bg-background px-2 pb-3 md:px-4 md:pb-4">
|
||||
|
||||
@ -8,10 +8,11 @@ import { VideoPreview } from "@/components/video-preview";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||
onImageEdit?: (imageUrl: string) => void;
|
||||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||||
};
|
||||
|
||||
export const Response = memo(
|
||||
({ className, onImageEdit, ...props }: ResponseProps) => (
|
||||
({ className, onImageEdit, onDrawingComplete, ...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",
|
||||
@ -22,7 +23,7 @@ export const Response = memo(
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
return <ImagePreview src={src} alt={alt} onImageEdit={onImageEdit} />;
|
||||
return <ImagePreview src={src} alt={alt} onImageEdit={onImageEdit} onDrawingComplete={onDrawingComplete} />;
|
||||
},
|
||||
video: ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
if (!src) {
|
||||
@ -35,7 +36,7 @@ export const Response = memo(
|
||||
/>
|
||||
),
|
||||
(prevProps: ResponseProps, nextProps: ResponseProps) =>
|
||||
prevProps.children === nextProps.children && prevProps.onImageEdit === nextProps.onImageEdit
|
||||
prevProps.children === nextProps.children && prevProps.onImageEdit === nextProps.onImageEdit && prevProps.onDrawingComplete === nextProps.onDrawingComplete
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
|
||||
@ -5,7 +5,7 @@ import { SparklesIcon } from "./icons";
|
||||
|
||||
/**
|
||||
* Gemini 图片生成加载占位符组件
|
||||
* 显示一个 170x200 像素的模糊动态效果
|
||||
* 显示 2 个 170x200 像素的模糊动态效果
|
||||
*/
|
||||
export const GeminiImageLoading = memo(() => {
|
||||
return (
|
||||
@ -30,7 +30,7 @@ export const GeminiImageLoading = memo(() => {
|
||||
{/* 文本提示 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-base font-medium text-foreground">
|
||||
正在生成图片
|
||||
正在生成 2 张图片
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<span
|
||||
@ -48,72 +48,78 @@ export const GeminiImageLoading = memo(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 图片占位符 - 170x200 像素,带模糊动态效果 */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
style={{ width: "170px", height: "200px" }}
|
||||
>
|
||||
{/* 底层渐变背景 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 via-pink-400 to-blue-400 opacity-60" />
|
||||
|
||||
{/* 动态模糊叠加层 1 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-tr from-purple-500/40 via-transparent to-pink-500/40 animate-pulse"
|
||||
style={{ animationDuration: "2s" }}
|
||||
/>
|
||||
|
||||
{/* 动态模糊叠加层 2 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-bl from-blue-500/30 via-transparent to-purple-500/30 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" }} />
|
||||
{/* 图片图标 */}
|
||||
<svg
|
||||
className="h-10 w-10 text-white/80 animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ animationDuration: "1.5s" }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/* 2 个图片占位符 - 横向排列,每个 170x200px */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{[0, 1].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
style={{ width: "170px", height: "200px" }}
|
||||
>
|
||||
{/* 底层渐变背景 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 via-pink-400 to-blue-400 opacity-60" />
|
||||
|
||||
{/* 动态模糊叠加层 1 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-tr from-purple-500/40 via-transparent to-pink-500/40 animate-pulse"
|
||||
style={{ animationDuration: "2s", animationDelay: `${index * 0.3}s` }}
|
||||
/>
|
||||
|
||||
{/* 动态模糊叠加层 2 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-bl from-blue-500/30 via-transparent to-purple-500/30 animate-pulse"
|
||||
style={{ animationDuration: "2.5s", animationDelay: `${0.5 + index * 0.3}s` }}
|
||||
/>
|
||||
|
||||
{/* 噪点纹理效果 */}
|
||||
<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", animationDelay: `${index * 0.2}s` }} />
|
||||
{/* 图片图标 */}
|
||||
<svg
|
||||
className="h-10 w-10 text-white/80 animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流光效果 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
|
||||
style={{
|
||||
animation: "shimmer 2s infinite",
|
||||
animationDelay: `${index * 0.4}s`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流光效果 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
|
||||
style={{
|
||||
animation: "shimmer 2s infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
AI 正在创作中,请稍候...
|
||||
Gemini 正在创作 2 张图片,请稍候...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,4 +140,3 @@ export const GeminiImageLoading = memo(() => {
|
||||
});
|
||||
|
||||
GeminiImageLoading.displayName = "GeminiImageLoading";
|
||||
|
||||
|
||||
474
components/image-annotator.tsx
Normal file
474
components/image-annotator.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
type Tool = "pen" | "arrow" | "circle" | "text";
|
||||
|
||||
type DrawingAction = {
|
||||
type: Tool;
|
||||
color: string;
|
||||
lineWidth: number;
|
||||
points?: { x: number; y: number }[];
|
||||
startX?: number;
|
||||
startY?: number;
|
||||
endX?: number;
|
||||
endY?: number;
|
||||
radius?: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
interface ImageAnnotatorProps {
|
||||
imageUrl: string;
|
||||
onConfirm: (annotatedImageUrl: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
"#FF0000", // 红色
|
||||
"#FF6B00", // 橙色
|
||||
"#FFCC00", // 黄色
|
||||
"#00CC00", // 绿色
|
||||
"#0099FF", // 蓝色
|
||||
"#9933FF", // 紫色
|
||||
"#FF0099", // 粉色
|
||||
"#FFFFFF", // 白色
|
||||
"#000000", // 黑色
|
||||
];
|
||||
|
||||
const LINE_WIDTHS = [2, 4, 6, 8];
|
||||
|
||||
export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotatorProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [tool, setTool] = useState<Tool>("pen");
|
||||
const [color, setColor] = useState("#FF0000");
|
||||
const [lineWidth, setLineWidth] = useState(4);
|
||||
const [actions, setActions] = useState<DrawingAction[]>([]);
|
||||
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(null);
|
||||
const [textInput, setTextInput] = useState("");
|
||||
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// 加载图片
|
||||
useEffect(() => {
|
||||
const img = new window.Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
setImage(img);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// 初始化 canvas - 适配弹窗大小
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !image || !containerRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
// 弹窗内画布区域大小(减去工具栏和边距)
|
||||
const containerWidth = 800; // 850 - padding
|
||||
const containerHeight = 350; // 550 - 工具栏高度
|
||||
const scaleX = containerWidth / image.width;
|
||||
const scaleY = containerHeight / image.height;
|
||||
const newScale = Math.min(scaleX, scaleY, 1); // 不放大,只缩小
|
||||
|
||||
setScale(newScale);
|
||||
|
||||
canvas.width = image.width * newScale;
|
||||
canvas.height = image.height * newScale;
|
||||
|
||||
// 计算居中偏移
|
||||
const offsetX = (containerWidth - canvas.width) / 2;
|
||||
const offsetY = (containerHeight - canvas.height) / 2;
|
||||
setOffset({ x: Math.max(0, offsetX), y: Math.max(0, offsetY) });
|
||||
|
||||
setCtx(context);
|
||||
redrawCanvas(context, image, newScale, []);
|
||||
}, [image]);
|
||||
|
||||
// 重绘画布
|
||||
const redrawCanvas = useCallback(
|
||||
(context: CanvasRenderingContext2D, img: HTMLImageElement, currentScale: number, drawActions: DrawingAction[]) => {
|
||||
const canvas = context.canvas;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制图片
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制所有动作
|
||||
for (const action of drawActions) {
|
||||
drawAction(context, action, currentScale);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 当 actions 变化时重绘
|
||||
useEffect(() => {
|
||||
if (ctx && image) {
|
||||
redrawCanvas(ctx, image, scale, actions);
|
||||
}
|
||||
}, [actions, ctx, image, scale, redrawCanvas]);
|
||||
|
||||
// 绘制单个动作
|
||||
const drawAction = (context: CanvasRenderingContext2D, action: DrawingAction, currentScale: number) => {
|
||||
context.strokeStyle = action.color;
|
||||
context.fillStyle = action.color;
|
||||
context.lineWidth = action.lineWidth;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
switch (action.type) {
|
||||
case "pen":
|
||||
if (action.points && action.points.length > 0) {
|
||||
context.beginPath();
|
||||
context.moveTo(action.points[0].x * currentScale, action.points[0].y * currentScale);
|
||||
for (let i = 1; i < action.points.length; i++) {
|
||||
context.lineTo(action.points[i].x * currentScale, action.points[i].y * currentScale);
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
break;
|
||||
|
||||
case "arrow":
|
||||
if (action.startX !== undefined && action.startY !== undefined && action.endX !== undefined && action.endY !== undefined) {
|
||||
const sx = action.startX * currentScale;
|
||||
const sy = action.startY * currentScale;
|
||||
const ex = action.endX * currentScale;
|
||||
const ey = action.endY * currentScale;
|
||||
|
||||
// 绘制线条
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy);
|
||||
context.lineTo(ex, ey);
|
||||
context.stroke();
|
||||
|
||||
// 绘制箭头
|
||||
const angle = Math.atan2(ey - sy, ex - sx);
|
||||
const arrowLength = 15 + action.lineWidth * 2;
|
||||
context.beginPath();
|
||||
context.moveTo(ex, ey);
|
||||
context.lineTo(ex - arrowLength * Math.cos(angle - Math.PI / 6), ey - arrowLength * Math.sin(angle - Math.PI / 6));
|
||||
context.lineTo(ex - arrowLength * Math.cos(angle + Math.PI / 6), ey - arrowLength * Math.sin(angle + Math.PI / 6));
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
break;
|
||||
|
||||
case "circle":
|
||||
if (action.startX !== undefined && action.startY !== undefined && action.radius !== undefined) {
|
||||
context.beginPath();
|
||||
context.arc(action.startX * currentScale, action.startY * currentScale, action.radius * currentScale, 0, 2 * Math.PI);
|
||||
context.stroke();
|
||||
}
|
||||
break;
|
||||
|
||||
case "text":
|
||||
if (action.startX !== undefined && action.startY !== undefined && action.text) {
|
||||
const fontSize = (action.fontSize || 20) * currentScale;
|
||||
context.font = `bold ${fontSize}px sans-serif`;
|
||||
context.fillText(action.text, action.startX * currentScale, action.startY * currentScale);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取鼠标/触摸位置(转换为原始图片坐标)
|
||||
const getPosition = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX: number;
|
||||
let clientY: number;
|
||||
|
||||
if ("touches" in e) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
|
||||
// 转换为原始图片坐标
|
||||
return {
|
||||
x: (clientX - rect.left) / scale,
|
||||
y: (clientY - rect.top) / scale,
|
||||
};
|
||||
};
|
||||
|
||||
// 开始绘制
|
||||
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (tool === "text") {
|
||||
const pos = getPosition(e);
|
||||
setTextPosition(pos);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDrawing(true);
|
||||
const pos = getPosition(e);
|
||||
|
||||
const newAction: DrawingAction = {
|
||||
type: tool,
|
||||
color,
|
||||
lineWidth,
|
||||
};
|
||||
|
||||
if (tool === "pen") {
|
||||
newAction.points = [pos];
|
||||
} else {
|
||||
newAction.startX = pos.x;
|
||||
newAction.startY = pos.y;
|
||||
newAction.endX = pos.x;
|
||||
newAction.endY = pos.y;
|
||||
if (tool === "circle") {
|
||||
newAction.radius = 0;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentAction(newAction);
|
||||
};
|
||||
|
||||
// 绘制中
|
||||
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDrawing || !currentAction || !ctx || !image) return;
|
||||
|
||||
const pos = getPosition(e);
|
||||
|
||||
const updatedAction = { ...currentAction };
|
||||
|
||||
if (tool === "pen" && updatedAction.points) {
|
||||
updatedAction.points = [...updatedAction.points, pos];
|
||||
} else if (tool === "arrow") {
|
||||
updatedAction.endX = pos.x;
|
||||
updatedAction.endY = pos.y;
|
||||
} else if (tool === "circle" && updatedAction.startX !== undefined && updatedAction.startY !== undefined) {
|
||||
const dx = pos.x - updatedAction.startX;
|
||||
const dy = pos.y - updatedAction.startY;
|
||||
updatedAction.radius = Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
setCurrentAction(updatedAction);
|
||||
|
||||
// 实时重绘
|
||||
redrawCanvas(ctx, image, scale, [...actions, updatedAction]);
|
||||
};
|
||||
|
||||
// 结束绘制
|
||||
const handleEnd = () => {
|
||||
if (!isDrawing || !currentAction) return;
|
||||
|
||||
setIsDrawing(false);
|
||||
setActions((prev) => [...prev, currentAction]);
|
||||
setCurrentAction(null);
|
||||
};
|
||||
|
||||
// 添加文字
|
||||
const handleAddText = () => {
|
||||
if (!textInput.trim() || !textPosition) return;
|
||||
|
||||
const textAction: DrawingAction = {
|
||||
type: "text",
|
||||
color,
|
||||
lineWidth,
|
||||
startX: textPosition.x,
|
||||
startY: textPosition.y,
|
||||
text: textInput,
|
||||
fontSize: 20 + lineWidth * 2,
|
||||
};
|
||||
|
||||
setActions((prev) => [...prev, textAction]);
|
||||
setTextInput("");
|
||||
setTextPosition(null);
|
||||
};
|
||||
|
||||
// 撤销
|
||||
const handleUndo = () => {
|
||||
setActions((prev) => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
// 清空
|
||||
const handleClear = () => {
|
||||
setActions([]);
|
||||
};
|
||||
|
||||
// 确认
|
||||
const handleConfirm = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
// 创建一个新的 canvas 用于导出原始尺寸
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = image.width;
|
||||
exportCanvas.height = image.height;
|
||||
const exportCtx = exportCanvas.getContext("2d");
|
||||
if (!exportCtx) return;
|
||||
|
||||
// 绘制原始图片
|
||||
exportCtx.drawImage(image, 0, 0);
|
||||
|
||||
// 绘制所有动作(使用原始尺寸)
|
||||
for (const action of actions) {
|
||||
drawAction(exportCtx, action, 1);
|
||||
}
|
||||
|
||||
// 导出为 data URL
|
||||
const dataUrl = exportCanvas.toDataURL("image/png");
|
||||
onConfirm(dataUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onCancel}>
|
||||
{/* 弹窗容器 */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
|
||||
style={{ width: "850px", height: "550px" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-base font-medium">图片标注</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
|
||||
<Undo className="mr-1 h-4 w-4" />
|
||||
撤销
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} disabled={actions.length === 0}>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
清空
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具选择栏 */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 border-b px-4 py-2">
|
||||
{/* 工具按钮 */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${tool === "pen" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
|
||||
onClick={() => setTool("pen")}
|
||||
title="画笔"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${tool === "arrow" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
|
||||
onClick={() => setTool("arrow")}
|
||||
title="箭头"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${tool === "circle" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
|
||||
onClick={() => setTool("circle")}
|
||||
title="圆圈"
|
||||
>
|
||||
<Circle className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${tool === "text" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
|
||||
onClick={() => setTool("text")}
|
||||
title="文字"
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 颜色选择 */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-6 w-6 rounded-full border-2 transition-transform ${color === c ? "scale-110 border-primary" : "border-transparent hover:scale-105"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setColor(c)}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 线宽选择 */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
{LINE_WIDTHS.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${lineWidth === w ? "bg-primary/20" : "hover:bg-muted-foreground/10"}`}
|
||||
onClick={() => setLineWidth(w)}
|
||||
title={`线宽 ${w}px`}
|
||||
>
|
||||
<div className="rounded-full bg-foreground" style={{ width: w + 2, height: w + 2 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="cursor-crosshair rounded-lg shadow-lg"
|
||||
onMouseDown={handleStart}
|
||||
onMouseMove={handleMove}
|
||||
onMouseUp={handleEnd}
|
||||
onMouseLeave={handleEnd}
|
||||
onTouchStart={handleStart}
|
||||
onTouchMove={handleMove}
|
||||
onTouchEnd={handleEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文字输入弹窗 */}
|
||||
{textPosition && (
|
||||
<div className="fixed inset-0 z-[310] flex items-center justify-center bg-black/50" onClick={() => setTextPosition(null)}>
|
||||
<div className="rounded-lg border bg-background p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-3 text-sm font-medium">输入标注文字</div>
|
||||
<input
|
||||
type="text"
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddText();
|
||||
}}
|
||||
className="mb-3 w-64 rounded-md border bg-muted px-3 py-2 text-foreground outline-none focus:border-primary"
|
||||
placeholder="输入文字..."
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setTextPosition(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleAddText} disabled={!textInput.trim()}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { X, Download, Edit, Target } from "lucide-react";
|
||||
import { X, Download, Edit, Target, Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt,
|
||||
onImageEdit,
|
||||
onAnnotate,
|
||||
onDrawingComplete,
|
||||
imageIndex
|
||||
}: {
|
||||
src: string;
|
||||
@ -19,11 +21,13 @@ export function ImagePreview({
|
||||
description: string;
|
||||
formattedText: string;
|
||||
}) => void;
|
||||
onDrawingComplete?: (annotatedImageUrl: 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 和宽度
|
||||
// 格式优先级:
|
||||
@ -197,6 +201,22 @@ export function ImagePreview({
|
||||
|
||||
{/* 悬停时显示的操作按钮 */}
|
||||
<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
|
||||
@ -276,6 +296,23 @@ export function ImagePreview({
|
||||
</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
|
||||
@ -360,6 +397,19 @@ export function ImagePreview({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片标注器 */}
|
||||
{showAnnotator && onDrawingComplete && (
|
||||
<ImageAnnotator
|
||||
imageUrl={original}
|
||||
onConfirm={(annotatedImageUrl) => {
|
||||
onDrawingComplete(annotatedImageUrl);
|
||||
setShowAnnotator(false);
|
||||
toast.success("标注图片已添加到聊天");
|
||||
}}
|
||||
onCancel={() => setShowAnnotator(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -146,6 +146,7 @@ const PurePreviewMessage = ({
|
||||
isReadonly,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
onImageEdit,
|
||||
onDrawingComplete,
|
||||
}: {
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
@ -157,6 +158,7 @@ const PurePreviewMessage = ({
|
||||
isReadonly: boolean;
|
||||
requiresScrollPadding: boolean;
|
||||
onImageEdit?: (imageUrl: string) => void;
|
||||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||||
}) => {
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
|
||||
@ -265,7 +267,7 @@ const PurePreviewMessage = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Response key={partIndex} onImageEdit={onImageEdit}>
|
||||
<Response key={partIndex} onImageEdit={onImageEdit} onDrawingComplete={onDrawingComplete}>
|
||||
{sanitizeText(part.content)}
|
||||
</Response>
|
||||
);
|
||||
|
||||
@ -35,6 +35,7 @@ type MessagesProps = {
|
||||
isArtifactVisible: boolean;
|
||||
selectedModelId: string;
|
||||
onImageEdit?: (imageUrl: string) => void;
|
||||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||||
};
|
||||
|
||||
function PureMessages({
|
||||
@ -48,6 +49,7 @@ function PureMessages({
|
||||
isReadonly,
|
||||
selectedModelId,
|
||||
onImageEdit,
|
||||
onDrawingComplete,
|
||||
}: MessagesProps) {
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
@ -212,6 +214,7 @@ function PureMessages({
|
||||
: undefined
|
||||
}
|
||||
onImageEdit={onImageEdit}
|
||||
onDrawingComplete={onDrawingComplete}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@ -749,6 +749,20 @@ function PureMultimodalInput({
|
||||
});
|
||||
toast.success(`已添加标注:${annotation.description}`);
|
||||
}}
|
||||
onDrawingComplete={(annotatedImageUrl) => {
|
||||
// 将标注后的图片替换原图片(或添加为新附件)
|
||||
const fileName = `annotated-${Date.now()}.png`;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: annotatedImageUrl,
|
||||
name: fileName,
|
||||
contentType: "image/png",
|
||||
},
|
||||
]);
|
||||
// 如果输入框为空,自动填入提示词
|
||||
setInput((prev) => prev || "请根据我的标注修改这张图片:");
|
||||
}}
|
||||
imageIndex={index + 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -6,8 +6,9 @@ import type { Attachment } from "@/lib/types";
|
||||
import { Loader } from "./elements/loader";
|
||||
import { CrossSmallIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
import { X, Download, Target } from "lucide-react";
|
||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
@ -19,6 +20,7 @@ export const PreviewAttachment = ({
|
||||
onDrop,
|
||||
draggable = false,
|
||||
onAnnotate,
|
||||
onDrawingComplete,
|
||||
imageIndex,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
@ -34,6 +36,7 @@ export const PreviewAttachment = ({
|
||||
description: string;
|
||||
formattedText: string;
|
||||
}) => void;
|
||||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||||
imageIndex?: number;
|
||||
}) => {
|
||||
const { name, url, contentType } = attachment;
|
||||
@ -41,6 +44,7 @@ export const PreviewAttachment = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [showAnnotator, setShowAnnotator] = useState(false);
|
||||
const [annotationPoints, setAnnotationPoints] = useState<Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
@ -244,6 +248,23 @@ export const PreviewAttachment = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 画图标注按钮 */}
|
||||
{onDrawingComplete && (
|
||||
<button
|
||||
className="rounded-lg bg-white p-2 text-gray-700 shadow-lg transition-colors hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowAnnotator(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
aria-label="画图标注"
|
||||
title="画图标注"
|
||||
>
|
||||
<Paintbrush size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
className="rounded-lg bg-white p-2 text-gray-700 shadow-lg transition-colors hover:bg-gray-100"
|
||||
@ -341,6 +362,19 @@ export const PreviewAttachment = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片标注器 */}
|
||||
{showAnnotator && onDrawingComplete && (
|
||||
<ImageAnnotator
|
||||
imageUrl={url}
|
||||
onConfirm={(annotatedImageUrl) => {
|
||||
onDrawingComplete(annotatedImageUrl);
|
||||
setShowAnnotator(false);
|
||||
toast.success("标注图片已添加到聊天");
|
||||
}}
|
||||
onCancel={() => setShowAnnotator(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -48,13 +48,13 @@ export const VolcanoVisionLoading = memo(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3 个图片占位符 - 横向排列,每个最大宽度 250px */}
|
||||
{/* 3 个图片占位符 - 横向排列,每个 170x200px */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
style={{ width: "250px", height: "250px" }}
|
||||
style={{ width: "170px", height: "200px" }}
|
||||
>
|
||||
{/* 底层渐变背景 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-orange-400 via-red-400 to-yellow-400 opacity-60" />
|
||||
|
||||
@ -574,13 +574,30 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) {
|
||||
imageSize: options.imageParams?.imageSize || "2K"
|
||||
};
|
||||
console.log("[Gemini Image] Final imageParams after defaults:", imageParams);
|
||||
const response = await sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams);
|
||||
// 提取图片并上传 base64 到 S3
|
||||
const imageUrls = await extractAndUploadImages(response);
|
||||
const markdownText = formatImageResponse(imageUrls, imageParams.imageSize);
|
||||
|
||||
// 🆕 并发发起两个请求,生成2张图片(类似豆包模型)
|
||||
console.log("[Gemini Image] Starting 2 concurrent requests...");
|
||||
const startTime = Date.now();
|
||||
|
||||
const [response1, response2] = await Promise.all([
|
||||
sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams),
|
||||
sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams),
|
||||
]);
|
||||
|
||||
console.log(`[Gemini Image] Both requests completed in ${Date.now() - startTime}ms`);
|
||||
|
||||
// 提取图片并上传 base64 到 S3(并行处理)
|
||||
const [imageUrls1, imageUrls2] = await Promise.all([
|
||||
extractAndUploadImages(response1),
|
||||
extractAndUploadImages(response2),
|
||||
]);
|
||||
|
||||
// 合并两次请求的图片结果
|
||||
const allImageUrls = [...imageUrls1, ...imageUrls2];
|
||||
const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize);
|
||||
|
||||
console.log(
|
||||
`[Gemini Image] Generated ${imageUrls.length} images (uploaded to S3)`
|
||||
`[Gemini Image] Generated ${allImageUrls.length} images from 2 concurrent requests (uploaded to S3)`
|
||||
);
|
||||
|
||||
// 返回 AI SDK v1 格式
|
||||
@ -614,13 +631,30 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) {
|
||||
imageSize: options.imageParams?.imageSize || "2K"
|
||||
};
|
||||
console.log("[Gemini Image] Final imageParams after defaults:", imageParams);
|
||||
const response = await sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams);
|
||||
// 提取图片并上传 base64 到 S3
|
||||
const imageUrls = await extractAndUploadImages(response);
|
||||
const markdownText = formatImageResponse(imageUrls, imageParams.imageSize);
|
||||
|
||||
// 🆕 并发发起两个请求,生成2张图片(类似豆包模型)
|
||||
console.log("[Gemini Image Stream] Starting 2 concurrent requests...");
|
||||
const startTime = Date.now();
|
||||
|
||||
const [response1, response2] = await Promise.all([
|
||||
sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams),
|
||||
sendGeminiRequest(baseURL, apiKey, options.messages, modelId, imageParams),
|
||||
]);
|
||||
|
||||
console.log(`[Gemini Image Stream] Both requests completed in ${Date.now() - startTime}ms`);
|
||||
|
||||
// 提取图片并上传 base64 到 S3(并行处理)
|
||||
const [imageUrls1, imageUrls2] = await Promise.all([
|
||||
extractAndUploadImages(response1),
|
||||
extractAndUploadImages(response2),
|
||||
]);
|
||||
|
||||
// 合并两次请求的图片结果
|
||||
const allImageUrls = [...imageUrls1, ...imageUrls2];
|
||||
const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize);
|
||||
|
||||
console.log(
|
||||
`[Gemini Image Stream] Generated ${imageUrls.length} images (uploaded to S3)`
|
||||
`[Gemini Image Stream] Generated ${allImageUrls.length} images from 2 concurrent requests (uploaded to S3)`
|
||||
);
|
||||
|
||||
// 一次性发送整个图片 markdown(不再分块,因为 base64 图片很大)
|
||||
|
||||
@ -183,17 +183,12 @@ function formatImageResponse(images: Array<{ thumbnail: string; original: string
|
||||
return "生成图片失败,未找到图片数据";
|
||||
}
|
||||
|
||||
// 如果只有一张图片,使用 Markdown 格式
|
||||
if (images.length === 1) {
|
||||
return `![thumb|${images[0].original}](${images[0].thumbnail})`;
|
||||
}
|
||||
|
||||
// 多张图片:每张图片用空格分隔,会自动横向排列
|
||||
const imagesMarkdown = images
|
||||
.map((img) => ``)
|
||||
.join(' ');
|
||||
|
||||
return imagesMarkdown;
|
||||
// 🆕 统一使用 ||| 分隔符格式(与 Gemini 保持一致)
|
||||
// 格式: 
|
||||
// 豆包固定生成 2K 图片
|
||||
return images
|
||||
.map((img) => ``)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user