feat: hardcode canvas vision to qwen3-vl and downscale pick-object input
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-20 22:33:51 +08:00
parent 5c2fd46534
commit 740860fde7
4 changed files with 193 additions and 65 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -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
View 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 调用失败");
}