fix: hardcode canvas vision routes to qwen2.5-vl with alibaba-image key
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:14:59 +08:00
parent 4222e723b0
commit 5c2fd46534
3 changed files with 133 additions and 180 deletions

View File

@ -1,52 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { generateText } from "ai";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
const BLOCKED_PROVIDER_TYPES = new Set([
"fal-video",
"qianwen-video",
"volcano-video",
"doubao-video",
"alibaba-image",
"ruxa-image",
"fal-image",
"volcano-vision",
]);
type VisionModelItem = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
function isQwenVisionModel(item: VisionModelItem) {
const providerType = String(item.provider?.type || "");
if (BLOCKED_PROVIDER_TYPES.has(providerType)) return false;
const modelText = `${item.model.modelId || ""} ${item.model.displayName || ""}`.toLowerCase();
return /qwen.*vl|通义.*vl|千问.*vl/.test(modelText);
}
function isQwen25VisionModel(item: VisionModelItem) {
if (!isQwenVisionModel(item)) return false;
const modelText = `${item.model.modelId || ""} ${item.model.displayName || ""}`.toLowerCase();
return /qwen[-_. ]?2\.?5[-_. ]?vl|qwen2\.5[-_. ]?vl/.test(modelText);
}
function scoreVisionCandidate(item: VisionModelItem) {
let score = 0;
const providerType = String(item.provider?.type || "");
if (isQwen25VisionModel(item)) score += 400;
if (item.model.supportsVision) score += 120;
if (providerType === "alibaba") score += 80;
if (providerType === "custom") score += 40;
if (item.model.isDefault) score += 20;
score -= Math.max(0, item.model.sortOrder ?? 0);
return score;
}
function pickAutoTitleModel(allModels: VisionModelItem[]) {
const candidates = allModels.filter((item) => isQwenVisionModel(item));
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => scoreVisionCandidate(b) - scoreVisionCandidate(a))[0];
}
import { callQwen25Vl } from "@/lib/ai/qwen25-vl";
export async function POST(request: NextRequest) {
console.log("[Auto-title] API called");
@ -65,56 +19,23 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Image URL is required" }, { status: 400 });
}
// 固定使用 Qwen2.5-VL(若未配置则降级到其它 Qwen-VL)
const modelsData = await getEnabledAiModelsWithProvider();
console.log("[Auto-title] Available models count:", modelsData.length);
console.log("[Auto-title] All model IDs:", modelsData.map(m => m.model.modelId));
const visionModelData = pickAutoTitleModel(modelsData);
console.log("[Auto-title] Selected vision model:", {
modelId: visionModelData?.model.modelId,
displayName: visionModelData?.model.displayName,
providerType: visionModelData?.provider?.type,
isQwen25: Boolean(visionModelData && isQwen25VisionModel(visionModelData)),
});
if (!visionModelData) {
console.log("[Auto-title] No Qwen VL model found, using default title");
return NextResponse.json({ title: "未命名画布" });
}
const visionModel = visionModelData.model;
const model = await getLanguageModelById(visionModel.id);
if (!model) {
console.log("[Auto-title] Failed to load vision model");
return NextResponse.json({ title: "未命名画布" });
}
console.log("[Auto-title] 使用硬编码模型: qwen2.5-vl");
// 构造 Prompt
const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。";
const { text } = await generateText({
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image', image: imageUrl },
],
},
],
const { text, model } = await callQwen25Vl({
imageUrl,
prompt,
temperature: 0.2,
maxOutputTokens: 50,
maxTokens: 50,
});
let title = (text || "").trim();
title = title.replace(/[\s\p{P}\p{S}]+/gu, "");
if (title.length > 4) title = title.slice(0, 4);
if (title.length < 2) title = "新画布";
console.log("[Auto-title] Generated title:", title);
console.log("[Auto-title] Generated title:", title, "model:", model);
return NextResponse.json({ title });
} catch (error) {

View File

@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { generateText } from "ai";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { uploadFile } from "@/lib/storage/service";
import { callQwen25Vl } from "@/lib/ai/qwen25-vl";
export const runtime = "nodejs";
export const maxDuration = 30;
@ -32,45 +30,6 @@ interface PickObjectResult {
};
}
const BLOCKED_PROVIDER_TYPES = new Set([
"fal-video",
"qianwen-video",
"volcano-video",
"doubao-video",
]);
type VisionModelItem = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
function isQwenVisionModel(item: VisionModelItem) {
if (BLOCKED_PROVIDER_TYPES.has(String(item.provider?.type || ""))) return false;
const modelText = `${item.model.displayName || ""} ${item.model.modelId || ""}`.toLowerCase();
return /qwen.*vl|通义.*vl|千问.*vl/.test(modelText);
}
function isQwen25VisionModel(item: VisionModelItem) {
if (!isQwenVisionModel(item)) return false;
const modelText = `${item.model.displayName || ""} ${item.model.modelId || ""}`.toLowerCase();
return /qwen[-_. ]?2\.?5[-_. ]?vl|qwen2\.5[-_. ]?vl/.test(modelText);
}
function scoreQwenVisionCandidate(item: VisionModelItem) {
let score = 0;
if (isQwen25VisionModel(item)) score += 400;
if (item.model.supportsVision) score += 120;
const providerType = String(item.provider?.type || "");
if (providerType === "alibaba") score += 80;
if (providerType === "custom") score += 40;
if (item.model.isDefault) score += 20;
score -= Math.max(0, item.model.sortOrder ?? 0);
return score;
}
function pickObjectVisionModel(allModels: VisionModelItem[]) {
const candidates = allModels.filter((item) => isQwenVisionModel(item));
if (candidates.length === 0) return null;
return [...candidates].sort((a, b) => scoreQwenVisionCandidate(b) - scoreQwenVisionCandidate(a))[0];
}
function inferFileExtFromMime(mimeType: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("webp")) return "webp";
@ -124,36 +83,7 @@ export async function POST(request: Request) {
const normalizedImage = await normalizeImageInput(imageData);
// 固定使用 Qwen2.5-VL(若未配置则降级到其它 Qwen-VL)
const allModels = await getEnabledAiModelsWithProvider();
const selectedModel = pickObjectVisionModel(allModels);
if (!selectedModel) {
console.log("[PickObject] No Qwen VL model found");
return NextResponse.json({
success: false,
error: '未配置可用的 Qwen-VL 模型(请配置 qwen2.5-vl)',
}, { status: 500 });
}
console.log(
"[PickObject] 使用模型:",
selectedModel.model.modelId,
selectedModel.model.displayName,
selectedModel.provider.type,
{
isQwen25: isQwen25VisionModel(selectedModel),
}
);
const model = await getLanguageModelById(selectedModel.model.id);
if (!model) {
console.log("[PickObject] Failed to get language model");
return NextResponse.json({
success: false,
error: '无法加载模型',
}, { status: 500 });
}
console.log("[PickObject] 使用硬编码模型: qwen2.5-vl");
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
const prompt = `
@ -183,28 +113,14 @@ ${POSITION_OPTIONS.join('、')}
console.log('[PickObject] 调用视觉模型...');
// 调用视觉模型,发送带红色标记的图片
const { text: responseText } = await generateText({
model,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: normalizedImage,
},
{
type: 'text',
text: prompt,
},
],
},
],
const { text: responseText, model } = await callQwen25Vl({
imageUrl: normalizedImage,
prompt,
temperature: 0.1,
maxTokens: 300,
} as any);
});
console.log('[PickObject] 模型返回内容:', responseText?.substring(0, 200));
console.log('[PickObject] 模型返回内容:', model, responseText?.substring(0, 200));
if (!responseText) {
return NextResponse.json({
@ -279,9 +195,9 @@ ${POSITION_OPTIONS.join('、')}
success: true,
object: result,
model: {
id: selectedModel.model.modelId,
name: selectedModel.model.displayName,
provider: selectedModel.provider.type,
id: model,
name: model,
provider: "alibaba-image-key",
},
});

116
lib/ai/qwen25-vl.ts Normal file
View File

@ -0,0 +1,116 @@
import "server-only";
import { getEnabledAiProviders } from "@/lib/db/queries";
const QWEN25_VL_MODELS = [
"qwen2.5-vl-72b-instruct",
"qwen2.5-vl-7b-instruct",
] 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 callQwen25Vl(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 QWEN25_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("Qwen2.5-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("Qwen2.5-VL 调用失败");
}