"use client"; import React, { useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import { X, Loader2, Crosshair } 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"; /** * 在图片上绘制标记点,返回带标记的图片 base64 * AI 通过看到这个红色标记来识别用户点击的位置 */ async function drawMarkerOnImage( imageUrl: string, clickX: number, clickY: number ): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; 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 = imageUrl; }); } 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 [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null); const overlayRef = useRef(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) => { 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; // 显示点击位置 setClickPosition({ 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); toast.success(`已添加: ${result.object.name}`); // 关闭拾取模式 setPickerMode(false); setClickPosition(null); } else { toast.error(result.error || '未能识别物品'); setClickPosition(null); } } catch (error) { console.error('物品拾取失败:', error); toast.error('识别失败,请重试'); setClickPosition(null); } finally { setPickerLoading(false); } }, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, addPendingPickedObject, setPickerMode]); // 关闭拾取模式 const handleClose = useCallback(() => { setPickerMode(false); setClickPosition(null); }, [setPickerMode]); if (!isPickerMode || !imageUrl) return null; return createPortal(
{ // 点击背景关闭 if (e.target === overlayRef.current) { handleClose(); } }} > {/* 顶部提示栏 */}
{isPickerLoading ? '正在识别...' : '点击图片中的物品进行拾取'}
{/* 图片容器 */}
{/* 图片 - 原始图片,无任何标记 */} 拾取目标 {/* 点击位置标记(脉冲动画)- 仅在前端显示,不影响发送给 AI 的图片 */} {clickPosition && (
{/* 脉冲圆点 */}
)} {/* 加载状态 */} {isPickerLoading && (
正在识别...
)}
, document.body ); }