- 新增 ObjectPickerOverlay 组件,支持点击图片拾取物品 - 新增 /api/pick-object API,调用 Qwen-VL 识别物品 - Canvas Store 扩展拾取模式状态管理 - ImageNode 工具栏新增拾取按钮 - CanvasChatPanel 集成物品标签,发送时自动构建精准prompt - 图片压缩至 600px 以内加速 AI 分析 - 物品名称限制 8 字,位置固定 4 字标准词库
222 lines
6.5 KiB
TypeScript
222 lines
6.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { db } from "@/lib/db";
|
||
import { aiProvider } from "@/lib/db/schema";
|
||
import { eq } from "drizzle-orm";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 30;
|
||
|
||
// 位置词库
|
||
const POSITION_OPTIONS = [
|
||
'左上区域', '中央顶部', '右上区域',
|
||
'左侧中部', '中央区域', '右侧中部',
|
||
'左下区域', '中央底部', '右下区域'
|
||
] as const;
|
||
|
||
interface PickObjectRequest {
|
||
imageData: string; // Base64 压缩后的图片
|
||
clickX: number; // 点击位置 X (0-1)
|
||
clickY: number; // 点击位置 Y (0-1)
|
||
originalImageUrl: string; // 原图 URL
|
||
}
|
||
|
||
interface PickObjectResult {
|
||
name: string;
|
||
position: string;
|
||
confidence: number;
|
||
boundingBox: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const body: PickObjectRequest = await request.json();
|
||
const { imageData, clickX, clickY } = body;
|
||
|
||
// 验证参数
|
||
if (!imageData) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: '缺少图片数据',
|
||
}, { status: 400 });
|
||
}
|
||
|
||
if (typeof clickX !== 'number' || typeof clickY !== 'number') {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: '缺少点击坐标',
|
||
}, { status: 400 });
|
||
}
|
||
|
||
// 获取 Qwen-VL 配置
|
||
const qwenProvider = await db.query.aiProvider.findFirst({
|
||
where: eq(aiProvider.providerId, 'qwen'),
|
||
});
|
||
|
||
if (!qwenProvider || !qwenProvider.isEnabled || !qwenProvider.apiKey) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: 'Qwen 服务未配置',
|
||
}, { status: 500 });
|
||
}
|
||
|
||
// 构建 prompt(严格限制输出格式)
|
||
const prompt = `
|
||
识别图片中坐标 (${(clickX * 100).toFixed(0)}%, ${(clickY * 100).toFixed(0)}%) 位置的物品。
|
||
|
||
【输出要求 - 严格遵守】
|
||
返回纯 JSON,无其他文字:
|
||
{
|
||
"name": "物品名称",
|
||
"position": "位置描述",
|
||
"confidence": 0.95,
|
||
"boundingBox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 }
|
||
}
|
||
|
||
【name 格式】≤8字,格式:[颜色/材质]+[物品]
|
||
正确:黑色沙发、白色花瓶、木质书架、玻璃茶几
|
||
错误:一个黑色的皮质沙发(太长)
|
||
|
||
【position 格式】固定4字,从以下选择:
|
||
${POSITION_OPTIONS.join('、')}
|
||
|
||
【boundingBox】相对坐标 0-1,精确到小数点后2位
|
||
`.trim();
|
||
|
||
// 调用 Qwen-VL API
|
||
const response = await fetch(`${qwenProvider.apiBaseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'}/chat/completions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${qwenProvider.apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'qwen-vl-max',
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: imageData, // Base64 格式的图片
|
||
},
|
||
},
|
||
{
|
||
type: 'text',
|
||
text: prompt,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
max_tokens: 200,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error('[PickObject] Qwen API error:', errorText);
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: 'AI 识别服务错误',
|
||
}, { status: 500 });
|
||
}
|
||
|
||
const data = await response.json();
|
||
const content = data.choices?.[0]?.message?.content;
|
||
|
||
if (!content) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: 'AI 未返回结果',
|
||
}, { status: 500 });
|
||
}
|
||
|
||
// 解析 JSON 结果
|
||
let result: PickObjectResult;
|
||
try {
|
||
// 尝试从返回内容中提取 JSON
|
||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||
if (!jsonMatch) {
|
||
throw new Error('未找到 JSON');
|
||
}
|
||
result = JSON.parse(jsonMatch[0]);
|
||
|
||
// 验证和修正结果
|
||
if (!result.name || result.name.length > 12) {
|
||
result.name = result.name?.substring(0, 8) || '未知物品';
|
||
}
|
||
|
||
// 验证位置是否在词库中
|
||
if (!POSITION_OPTIONS.includes(result.position as any)) {
|
||
// 根据点击位置推算
|
||
const posX = clickX < 0.33 ? '左' : clickX > 0.66 ? '右' : '中央';
|
||
const posY = clickY < 0.33 ? '上' : clickY > 0.66 ? '下' : '中';
|
||
if (posX === '中央') {
|
||
result.position = posY === '中' ? '中央区域' : posY === '上' ? '中央顶部' : '中央底部';
|
||
} else {
|
||
result.position = posY === '中' ? `${posX}侧中部` : `${posX}${posY}区域`;
|
||
}
|
||
}
|
||
|
||
// 确保 boundingBox 有效
|
||
if (!result.boundingBox ||
|
||
typeof result.boundingBox.x !== 'number' ||
|
||
typeof result.boundingBox.y !== 'number') {
|
||
// 根据点击位置生成默认边界框
|
||
result.boundingBox = {
|
||
x: Math.max(0, clickX - 0.15),
|
||
y: Math.max(0, clickY - 0.15),
|
||
width: 0.3,
|
||
height: 0.3,
|
||
};
|
||
}
|
||
|
||
// 确保 confidence 有效
|
||
if (typeof result.confidence !== 'number' || result.confidence < 0 || result.confidence > 1) {
|
||
result.confidence = 0.85;
|
||
}
|
||
|
||
} catch (parseError) {
|
||
console.error('[PickObject] JSON parse error:', parseError, 'Content:', content);
|
||
|
||
// 返回默认结果
|
||
const posX = clickX < 0.33 ? '左' : clickX > 0.66 ? '右' : '中央';
|
||
const posY = clickY < 0.33 ? '上' : clickY > 0.66 ? '下' : '中';
|
||
const position = posX === '中央'
|
||
? (posY === '中' ? '中央区域' : posY === '上' ? '中央顶部' : '中央底部')
|
||
: (posY === '中' ? `${posX}侧中部` : `${posX}${posY}区域`);
|
||
|
||
result = {
|
||
name: '物品',
|
||
position,
|
||
confidence: 0.5,
|
||
boundingBox: {
|
||
x: Math.max(0, clickX - 0.15),
|
||
y: Math.max(0, clickY - 0.15),
|
||
width: 0.3,
|
||
height: 0.3,
|
||
},
|
||
};
|
||
}
|
||
|
||
console.log('[PickObject] 识别结果:', result);
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
object: result,
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('[PickObject] Error:', error);
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: `识别失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
}, { status: 500 });
|
||
}
|
||
}
|