feat: hardcode canvas vision to qwen3-vl and downscale pick-object input
This commit is contained in:
parent
5c2fd46534
commit
740860fde7
@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { callQwen25Vl } from "@/lib/ai/qwen25-vl";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
console.log("[Auto-title] API called");
|
||||
@ -19,12 +19,12 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Image URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log("[Auto-title] 使用硬编码模型: qwen2.5-vl");
|
||||
console.log("[Auto-title] 使用硬编码模型: qwen3-vl");
|
||||
|
||||
// 构造 Prompt
|
||||
const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。";
|
||||
|
||||
const { text, model } = await callQwen25Vl({
|
||||
const { text, model } = await callQwen3Vl({
|
||||
imageUrl,
|
||||
prompt,
|
||||
temperature: 0.2,
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { generateText } from "ai";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
@ -18,26 +16,6 @@ interface OcrItem {
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
function pickVisionModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
|
||||
const normalized = models
|
||||
.filter((item) => item.model.supportsVision)
|
||||
.sort((a, b) => {
|
||||
const aScore =
|
||||
(a.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
|
||||
(a.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
|
||||
(a.provider.type === "alibaba" ? 20 : 0) +
|
||||
(a.model.isDefault ? 5 : 0);
|
||||
const bScore =
|
||||
(b.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
|
||||
(b.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) +
|
||||
(b.provider.type === "alibaba" ? 20 : 0) +
|
||||
(b.model.isDefault ? 5 : 0);
|
||||
return bScore - aScore;
|
||||
});
|
||||
|
||||
return normalized[0];
|
||||
}
|
||||
|
||||
function parseJsonText(raw: string): any | null {
|
||||
if (!raw) return null;
|
||||
|
||||
@ -124,18 +102,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
|
||||
}
|
||||
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const selected = pickVisionModel(models);
|
||||
|
||||
if (!selected) {
|
||||
return NextResponse.json({ success: false, error: "未配置可用的视觉模型" }, { status: 500 });
|
||||
}
|
||||
|
||||
const model = await getLanguageModelById(selected.model.id);
|
||||
if (!model) {
|
||||
return NextResponse.json({ success: false, error: "视觉模型加载失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log("[Canvas Text Detect] 使用硬编码模型: qwen3-vl");
|
||||
const prompt = [
|
||||
"你是图片文字识别助手。",
|
||||
"请识别图片中可见文字并按阅读顺序输出 JSON。",
|
||||
@ -143,19 +110,11 @@ export async function POST(request: Request) {
|
||||
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
|
||||
"如果没有识别到文字,返回 {\"items\":[]}。",
|
||||
].join("\n");
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image", image: imageUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
const { text, model } = await callQwen3Vl({
|
||||
imageUrl,
|
||||
prompt,
|
||||
temperature: 0.1,
|
||||
maxTokens: 800,
|
||||
});
|
||||
|
||||
const parsedJson = parseJsonText(text || "");
|
||||
@ -173,7 +132,7 @@ export async function POST(request: Request) {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
model: selected.model.displayName,
|
||||
model,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { callQwen25Vl } from "@/lib/ai/qwen25-vl";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
@ -30,11 +31,53 @@ interface PickObjectResult {
|
||||
};
|
||||
}
|
||||
|
||||
function inferFileExtFromMime(mimeType: string) {
|
||||
if (mimeType.includes("png")) return "png";
|
||||
if (mimeType.includes("webp")) return "webp";
|
||||
if (mimeType.includes("gif")) return "gif";
|
||||
return "jpg";
|
||||
const MAX_VISION_SIDE = 500;
|
||||
const PICK_OBJECT_FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
async function downscaleImageForVision(buffer: Buffer) {
|
||||
const source = sharp(buffer, { failOn: "none" });
|
||||
const meta = await source.metadata();
|
||||
const hasAlpha = Boolean(meta.hasAlpha);
|
||||
const width = meta.width || 0;
|
||||
const height = meta.height || 0;
|
||||
|
||||
const resized = source
|
||||
.rotate()
|
||||
.resize({
|
||||
width: MAX_VISION_SIDE,
|
||||
height: MAX_VISION_SIDE,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (hasAlpha) {
|
||||
return {
|
||||
buffer: await resized.png({ compressionLevel: 9 }).toBuffer(),
|
||||
ext: "png",
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer(),
|
||||
ext: "jpg",
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string) {
|
||||
const { buffer: processedBuffer, ext, width, height } = await downscaleImageForVision(buffer);
|
||||
const filename = `${filenamePrefix}-${Date.now()}.${ext}`;
|
||||
const uploaded = await uploadFile(filename, processedBuffer as any);
|
||||
console.log("[PickObject] 视觉分析图片已缩放:", {
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
maxSide: MAX_VISION_SIDE,
|
||||
outputExt: ext,
|
||||
});
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
@ -42,7 +85,21 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
if (!raw) throw new Error("缺少图片数据");
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return raw;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PICK_OBJECT_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(raw, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`拉取图片失败: HTTP ${response.status}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const base64Match = raw.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
|
||||
@ -50,13 +107,9 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
throw new Error("图片格式不支持");
|
||||
}
|
||||
|
||||
const mimeType = base64Match[1];
|
||||
const base64Body = base64Match[2];
|
||||
const buffer = Buffer.from(base64Body, "base64");
|
||||
const ext = inferFileExtFromMime(mimeType);
|
||||
const filename = `pick-object-${Date.now()}.${ext}`;
|
||||
const uploaded = await uploadFile(filename, buffer as any);
|
||||
return uploaded.url;
|
||||
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -83,7 +136,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const normalizedImage = await normalizeImageInput(imageData);
|
||||
|
||||
console.log("[PickObject] 使用硬编码模型: qwen2.5-vl");
|
||||
console.log("[PickObject] 使用硬编码模型: qwen3-vl");
|
||||
|
||||
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
||||
const prompt = `
|
||||
@ -113,7 +166,7 @@ ${POSITION_OPTIONS.join('、')}
|
||||
console.log('[PickObject] 调用视觉模型...');
|
||||
|
||||
// 调用视觉模型,发送带红色标记的图片
|
||||
const { text: responseText, model } = await callQwen25Vl({
|
||||
const { text: responseText, model } = await callQwen3Vl({
|
||||
imageUrl: normalizedImage,
|
||||
prompt,
|
||||
temperature: 0.1,
|
||||
|
||||
116
lib/ai/qwen3-vl.ts
Normal file
116
lib/ai/qwen3-vl.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import "server-only";
|
||||
|
||||
import { getEnabledAiProviders } from "@/lib/db/queries";
|
||||
|
||||
const QWEN3_VL_MODELS = [
|
||||
"qwen3-vl-plus",
|
||||
"qwen-vl-plus",
|
||||
] as const;
|
||||
|
||||
const QWEN_COMPAT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
|
||||
function parseAssistantText(payload: any): string {
|
||||
const choices = Array.isArray(payload?.choices) ? payload.choices : [];
|
||||
const content = choices[0]?.message?.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const chunks = content
|
||||
.map((part: any) => (typeof part?.text === "string" ? part.text.trim() : ""))
|
||||
.filter(Boolean);
|
||||
return chunks.join("\n").trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function getQwenImageProviderApiKey() {
|
||||
const providers = await getEnabledAiProviders();
|
||||
const provider = providers.find(
|
||||
(item) => item.type === "alibaba-image" && typeof item.apiKey === "string" && item.apiKey.trim(),
|
||||
);
|
||||
|
||||
if (!provider?.apiKey) {
|
||||
throw new Error("未配置可用的千问生图 Provider API Key");
|
||||
}
|
||||
|
||||
return provider.apiKey.trim();
|
||||
}
|
||||
|
||||
export async function callQwen3Vl(params: {
|
||||
imageUrl: string;
|
||||
prompt: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}) {
|
||||
const apiKey = await getQwenImageProviderApiKey();
|
||||
const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const model of QWEN3_VL_MODELS) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: params.temperature ?? 0.1,
|
||||
max_tokens: params.maxTokens ?? 300,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: params.prompt },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: params.imageUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.error?.message || payload?.message || `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const text = parseAssistantText(payload);
|
||||
if (!text) {
|
||||
throw new Error("Qwen3-VL 未返回文本内容");
|
||||
}
|
||||
|
||||
return { text, model };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
const errorText = String(lastError.message || "");
|
||||
const isModelNotFound =
|
||||
/model.*not.*found|invalid model|not support/i.test(errorText);
|
||||
if (!isModelNotFound) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Qwen3-VL 调用失败");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user