diff --git a/app/api/canvas/auto-title/route.ts b/app/api/canvas/auto-title/route.ts index 5148d859f..ae10c1c97 100644 --- a/app/api/canvas/auto-title/route.ts +++ b/app/api/canvas/auto-title/route.ts @@ -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, diff --git a/app/api/canvas/text-detect/route.ts b/app/api/canvas/text-detect/route.ts index 2e686e6a4..1b91668ef 100644 --- a/app/api/canvas/text-detect/route.ts +++ b/app/api/canvas/text-detect/route.ts @@ -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>) { - 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) { diff --git a/app/api/pick-object/route.ts b/app/api/pick-object/route.ts index 9ddf2fbfa..4a0720d25 100644 --- a/app/api/pick-object/route.ts +++ b/app/api/pick-object/route.ts @@ -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 { @@ -42,7 +85,21 @@ async function normalizeImageInput(imageData: string): Promise { 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 { 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, diff --git a/lib/ai/qwen3-vl.ts b/lib/ai/qwen3-vl.ts new file mode 100644 index 000000000..00c1a0e53 --- /dev/null +++ b/lib/ai/qwen3-vl.ts @@ -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 调用失败"); +}