ai-chatbot/components/canvas/ObjectPickerOverlay.tsx
Your Name a6083ac409
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: serve aliyun oss previews directly
2026-04-29 21:42:03 +08:00

371 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 React, { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Loader2, Crosshair, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCanvasStore, type PickedObject } from "@/store/canvas";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery";
// 已标注的物品(本地状态,用于显示标记)
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
ref={overlayRef}
className="fixed inset-0 z-[9998] bg-black/60 flex items-center justify-center"
onClick={(e) => {
// 点击背景不关闭,需要点击完成按钮
if (e.target === overlayRef.current && pickedMarkers.length === 0) {
handleClose();
}
}}
>
{/* 顶部提示栏 */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-lg">
<Crosshair className="h-5 w-5 text-orange-500" />
<span className="text-sm font-medium">
{isPickerLoading
? '正在识别...'
: pickedMarkers.length > 0
? `已标注 ${pickedMarkers.length} 个物品`
: '点击图片中的物品进行标注'}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 图片容器 */}
<div className="relative max-w-[80vw] max-h-[70vh]">
{/* 图片 - 原始图片,无任何标记 */}
<img
src={imageUrl}
alt="拾取目标"
className={cn(
"max-w-full max-h-[70vh] object-contain rounded-lg border-4 transition-all",
isPickerLoading ? "border-orange-400 opacity-80" : "border-orange-500",
!isPickerLoading && "cursor-crosshair"
)}
onClick={!isPickerLoading ? handleImageClick : undefined}
draggable={false}
/>
{/* 已标注的物品标记 */}
{pickedMarkers.map((marker) => (
<div
key={`${marker.clickX}-${marker.clickY}`}
className="absolute pointer-events-none"
style={{
left: `${marker.clickX * 100}%`,
top: `${marker.clickY * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 序号标记 */}
<div className="relative flex items-center justify-center">
<div className="w-6 h-6 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
<span className="text-xs font-bold text-white">{marker.index}</span>
</div>
</div>
{/* 物品名称 */}
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-blue-500 text-white text-xs px-2 py-0.5 rounded whitespace-nowrap shadow-lg">
{marker.objectName}
</div>
</div>
))}
{/* 当前正在识别的点击位置标记(脉冲动画) */}
{currentClick && (
<div
className="absolute pointer-events-none"
style={{
left: `${currentClick.x * 100}%`,
top: `${currentClick.y * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 脉冲圆点 */}
<div className={cn(
"relative w-8 h-8 rounded-full border-2 border-orange-500 bg-orange-500/30",
isPickerLoading && "animate-pulse"
)}>
<div className="absolute inset-0 rounded-full bg-orange-500 animate-ping 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 bg-black/20 rounded-lg">
<div className="bg-white rounded-lg px-4 py-3 flex items-center gap-2 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="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-white rounded-full px-3 py-2 shadow-lg max-w-[80vw]">
<div className="flex items-center gap-2 overflow-x-auto">
<span className="text-xs text-gray-500 flex-shrink-0">已标注:</span>
{pickedMarkers.map((marker) => (
<span
key={`list-${marker.clickX}-${marker.clickY}`}
className="inline-flex items-center gap-1 bg-blue-50 text-blue-700 text-xs px-2 py-1 rounded-full flex-shrink-0"
>
<span className="w-4 h-4 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center">
{marker.index}
</span>
{marker.objectName}
</span>
))}
</div>
{/* 完成按钮 - 黑色风格 */}
<Button
size="sm"
className="h-8 rounded-full bg-black hover:bg-black/90 text-white px-4 flex-shrink-0"
onClick={handleFinish}
>
<Check className="h-4 w-4 mr-1" />
完成
</Button>
</div>
)}
</div>,
document.body
);
}