Your Name 47afddfd73 feat: 添加物品拾取功能(MVP版本)
- 新增 ObjectPickerOverlay 组件,支持点击图片拾取物品
- 新增 /api/pick-object API,调用 Qwen-VL 识别物品
- Canvas Store 扩展拾取模式状态管理
- ImageNode 工具栏新增拾取按钮
- CanvasChatPanel 集成物品标签,发送时自动构建精准prompt
- 图片压缩至 600px 以内加速 AI 分析
- 物品名称限制 8 字,位置固定 4 字标准词库
2026-02-02 11:50:30 +08:00

222 lines
6.5 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.

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 });
}
}