ai-chatbot/components/canvas/ObjectPickerOverlay.tsx
2026-05-26 19:32:19 +08:00

404 lines
13 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 { Check, Crosshair, Loader2, X } from "lucide-react";
import type React from "react";
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import { type PickedObject, useCanvasStore } from "@/store/canvas";
// 已标注的物品(本地状态,用于显示标记)
interface PickedMarker {
clickX: number;
clickY: number;
objectName: string;
index: number;
}
/**
* 在图片上绘制标记点,返回带标记的图片 base64
* AI 通过看到这个红色标记来识别用户点击的位置
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
const loadUrl = buildImageProxyUrl(imageUrl, { delivery: "proxy" });
img.onload = () => {
// 创建 canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("无法创建 canvas"));
return;
}
// 压缩图片尺寸(最大 800px),加快 AI 分析速度
const maxSize = 800;
let width = img.width;
let height = img.height;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height / width) * maxSize;
width = maxSize;
} else {
width = (width / height) * maxSize;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
// 绘制图片
ctx.drawImage(img, 0, 0, width, height);
// 计算标记位置
const markerX = clickX * width;
const markerY = clickY * height;
const markerRadius = Math.max(12, Math.min(width, height) * 0.025); // 标记大小
// 绘制红色标记圆圈(带白色边框,更醒目)
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
ctx.fillStyle = "white";
ctx.fill();
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
ctx.fillStyle = "#FF0000";
ctx.fill();
// 绘制十字线(帮助 AI 定位)
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
// 水平线
ctx.beginPath();
ctx.moveTo(markerX - markerRadius * 2, markerY);
ctx.lineTo(markerX - markerRadius - 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX + markerRadius + 2, markerY);
ctx.lineTo(markerX + markerRadius * 2, markerY);
ctx.stroke();
// 垂直线
ctx.beginPath();
ctx.moveTo(markerX, markerY - markerRadius * 2);
ctx.lineTo(markerX, markerY - markerRadius - 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY + markerRadius + 2);
ctx.lineTo(markerX, markerY + markerRadius * 2);
ctx.stroke();
// 转为 base64
const markedImageBase64 = canvas.toDataURL("image/jpeg", 0.85);
resolve(markedImageBase64);
};
img.onerror = () => {
reject(new Error("图片加载失败"));
};
img.src = loadUrl;
});
}
export function ObjectPickerOverlay() {
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
const pickerTargetNodeId = useCanvasStore(
(state) => state.pickerTargetNodeId
);
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
const addPendingPickedObject = useCanvasStore(
(state) => state.addPendingPickedObject
);
const getNode = useCanvasStore((state) => state.getNode);
// 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{
x: number;
y: number;
} | null>(null);
// 已成功标注的物品列表(本次会话中)
const [pickedMarkers, setPickedMarkers] = useState<PickedMarker[]>([]);
const overlayRef = useRef<HTMLDivElement>(null);
// 获取目标节点
const targetNode = pickerTargetNodeId ? getNode(pickerTargetNodeId) : null;
const imageUrl =
targetNode?.type === "image"
? ((targetNode.data as any)?.src as string | undefined)
: undefined;
// 处理图片点击 - 识别成功后添加到对话框,但不关闭拾取模式
const handleImageClick = useCallback(
async (e: React.MouseEvent<HTMLImageElement>) => {
if (isPickerLoading || !imageUrl || !pickerTargetNodeId) return;
const img = e.currentTarget;
const rect = img.getBoundingClientRect();
// 计算相对坐标 (0-1)
const clickX = (e.clientX - rect.left) / rect.width;
const clickY = (e.clientY - rect.top) / rect.height;
// 显示点击位置
setCurrentClick({ x: clickX, y: clickY });
setPickerLoading(true);
try {
// 在图片上绘制红色标记点,让 AI 能看到用户点击的位置
const markedImageBase64 = await drawMarkerOnImage(
imageUrl,
clickX,
clickY
);
// 发送带标记的图片给 AI
const response = await fetch("/api/pick-object", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageData: markedImageBase64, // 带红色标记的图片
clickX,
clickY,
}),
});
if (!response.ok) {
throw new Error("识别失败");
}
const result = await response.json();
if (result.success && result.object) {
// 构建拾取结果(保存原始图片 URL,不是带标记的)
const picked: PickedObject = {
imageUrl, // 原始图片
imageNodeId: pickerTargetNodeId,
clickX,
clickY,
objectName: result.object.name,
objectPosition: result.object.position,
confidence: result.object.confidence,
boundingBox: result.object.boundingBox,
};
// 添加到对话框
addPendingPickedObject(picked);
// 添加到已标注列表(用于显示标记)
setPickedMarkers((prev) => [
...prev,
{
clickX,
clickY,
objectName: result.object.name,
index: prev.length + 1,
},
]);
toast.success(`已添加: ${result.object.name},可继续标注其他物品`);
// 清除当前点击位置,但不关闭拾取模式
setCurrentClick(null);
} else {
toast.error(result.error || "未能识别物品");
setCurrentClick(null);
}
} catch (error) {
console.error("物品拾取失败:", error);
toast.error("识别失败,请重试");
setCurrentClick(null);
} finally {
setPickerLoading(false);
}
},
[
imageUrl,
isPickerLoading,
pickerTargetNodeId,
setPickerLoading,
addPendingPickedObject,
]
);
// 完成标注,关闭拾取模式
const handleFinish = useCallback(() => {
setPickerMode(false);
setCurrentClick(null);
setPickedMarkers([]);
if (pickedMarkers.length > 0) {
toast.success(`已完成 ${pickedMarkers.length} 个物品标注`);
}
}, [setPickerMode, pickedMarkers.length]);
// 关闭拾取模式(不保存)
const handleClose = useCallback(() => {
setPickerMode(false);
setCurrentClick(null);
setPickedMarkers([]);
}, [setPickerMode]);
// 🆕 改为直接在 ImageNode 中标注,不再使用全屏覆盖层
// 保留此组件以便将来可能需要切换回覆盖层模式
return null;
// 原覆盖层代码(已禁用)
if (!isPickerMode || !imageUrl) return null;
return createPortal(
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60"
onClick={(e) => {
// 点击背景不关闭,需要点击完成按钮
if (e.target === overlayRef.current && pickedMarkers.length === 0) {
handleClose();
}
}}
ref={overlayRef}
>
{/* 顶部提示栏 */}
<div className="-translate-x-1/2 absolute top-4 left-1/2 flex items-center gap-3 rounded-full bg-white px-4 py-2 shadow-lg">
<Crosshair className="h-5 w-5 text-orange-500" />
<span className="font-medium text-sm">
{isPickerLoading
? "正在识别..."
: pickedMarkers.length > 0
? `已标注 ${pickedMarkers.length} 个物品`
: "点击图片中的物品进行标注"}
</span>
<Button
className="h-7 w-7 rounded-full"
onClick={handleClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 图片容器 */}
<div className="relative max-h-[70vh] max-w-[80vw]">
{/* 图片 - 原始图片,无任何标记 */}
<img
alt="拾取目标"
className={cn(
"max-h-[70vh] max-w-full rounded-lg border-4 object-contain transition-all",
isPickerLoading
? "border-orange-400 opacity-80"
: "border-orange-500",
!isPickerLoading && "cursor-crosshair"
)}
draggable={false}
onClick={isPickerLoading ? undefined : handleImageClick}
src={imageUrl}
/>
{/* 已标注的物品标记 */}
{pickedMarkers.map((marker) => (
<div
className="pointer-events-none absolute"
key={`${marker.clickX}-${marker.clickY}`}
style={{
left: `${marker.clickX * 100}%`,
top: `${marker.clickY * 100}%`,
transform: "translate(-50%, -50%)",
}}
>
{/* 序号标记 */}
<div className="relative flex items-center justify-center">
<div className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-blue-500 shadow-lg">
<span className="font-bold text-white text-xs">
{marker.index}
</span>
</div>
</div>
{/* 物品名称 */}
<div className="-translate-x-1/2 absolute top-full left-1/2 mt-1 whitespace-nowrap rounded bg-blue-500 px-2 py-0.5 text-white text-xs shadow-lg">
{marker.objectName}
</div>
</div>
))}
{/* 当前正在识别的点击位置标记(脉冲动画) */}
{currentClick && (
<div
className="pointer-events-none absolute"
style={{
left: `${(currentClick?.x ?? 0) * 100}%`,
top: `${(currentClick?.y ?? 0) * 100}%`,
transform: "translate(-50%, -50%)",
}}
>
{/* 脉冲圆点 */}
<div
className={cn(
"relative h-8 w-8 rounded-full border-2 border-orange-500 bg-orange-500/30",
isPickerLoading && "animate-pulse"
)}
>
<div className="absolute inset-0 animate-ping rounded-full bg-orange-500 opacity-30" />
<div className="absolute inset-2 rounded-full bg-orange-500" />
</div>
</div>
)}
{/* 加载状态 */}
{isPickerLoading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/20">
<div className="flex items-center gap-2 rounded-lg bg-white px-4 py-3 shadow-lg">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
<span className="text-sm">正在识别...</span>
</div>
</div>
)}
</div>
{/* 底部工具栏:已标注物品列表 + 完成按钮 */}
{pickedMarkers.length > 0 && (
<div className="-translate-x-1/2 absolute bottom-4 left-1/2 flex max-w-[80vw] items-center gap-3 rounded-full bg-white px-3 py-2 shadow-lg">
<div className="flex items-center gap-2 overflow-x-auto">
<span className="flex-shrink-0 text-gray-500 text-xs">已标注:</span>
{pickedMarkers.map((marker) => (
<span
className="inline-flex flex-shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-blue-700 text-xs"
key={`list-${marker.clickX}-${marker.clickY}`}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-xs">
{marker.index}
</span>
{marker.objectName}
</span>
))}
</div>
{/* 完成按钮 - 黑色风格 */}
<Button
className="h-8 flex-shrink-0 rounded-full bg-black px-4 text-white hover:bg-black/90"
onClick={handleFinish}
size="sm"
>
<Check className="mr-1 h-4 w-4" />
完成
</Button>
</div>
)}
</div>,
document.body
);
}