From 5c2fd46534750691615f4802054b7b3a2491c72c Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 22:14:59 +0800 Subject: [PATCH] fix: hardcode canvas vision routes to qwen2.5-vl with alibaba-image key --- app/api/canvas/auto-title/route.ts | 93 ++--------------------- app/api/pick-object/route.ts | 104 +++----------------------- lib/ai/qwen25-vl.ts | 116 +++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 180 deletions(-) create mode 100644 lib/ai/qwen25-vl.ts diff --git a/app/api/canvas/auto-title/route.ts b/app/api/canvas/auto-title/route.ts index 6715f9f2e..5148d859f 100644 --- a/app/api/canvas/auto-title/route.ts +++ b/app/api/canvas/auto-title/route.ts @@ -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>[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) { diff --git a/app/api/pick-object/route.ts b/app/api/pick-object/route.ts index 17850ba88..9ddf2fbfa 100644 --- a/app/api/pick-object/route.ts +++ b/app/api/pick-object/route.ts @@ -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>[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", }, }); diff --git a/lib/ai/qwen25-vl.ts b/lib/ai/qwen25-vl.ts new file mode 100644 index 000000000..fe65193aa --- /dev/null +++ b/lib/ai/qwen25-vl.ts @@ -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 调用失败"); +}