ai-chatbot/lib/ai/qwen3-vl.ts

155 lines
4.5 KiB
TypeScript

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 getQwenVisionProviderApiKey() {
const providers = await getEnabledAiProviders();
const hasApiKey = (item: (typeof providers)[number]) =>
typeof item.apiKey === "string" && Boolean(item.apiKey.trim());
const provider =
providers.find((item) => item.type === "alibaba" && hasApiKey(item)) ||
providers.find((item) => item.type === "alibaba-image" && hasApiKey(item));
if (!provider?.apiKey) {
throw new Error("未配置可用的阿里云多模态 Provider API Key");
}
return provider.apiKey.trim();
}
export async function callQwen3Vl(params: {
imageUrl?: string;
imageUrls?: string[];
model?: string;
prompt: string;
temperature?: number;
maxTokens?: number;
timeoutMs?: number;
retries?: number;
}) {
const apiKey = await getQwenVisionProviderApiKey();
const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`;
const timeoutMs = Math.max(10000, Math.min(params.timeoutMs ?? 45000, 120000));
const retries = Math.max(0, Math.min(params.retries ?? 1, 2));
const imageUrls = [
...(Array.isArray(params.imageUrls) ? params.imageUrls : []),
params.imageUrl || "",
]
.map((item) => String(item || "").trim())
.filter(Boolean);
if (imageUrls.length === 0) {
throw new Error("Qwen3-VL 调用缺少图片输入");
}
const preferredModel = String(params.model || "").trim();
const models = Array.from(
new Set([
...(/^qwen.*(?:vl|vision)/i.test(preferredModel) ? [preferredModel] : []),
...QWEN3_VL_MODELS,
])
);
let lastError: Error | null = null;
for (const model of models) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
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 },
...imageUrls.map((url) => ({
type: "image_url",
image_url: {
url,
},
})),
],
},
],
}),
});
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 || "").toLowerCase();
const isModelNotFound =
/model.*not.*found|invalid model|not support/i.test(errorText);
const isRetriable =
/abort|timeout|timed out|ecconnreset|econnreset|429|rate limit|503/.test(errorText);
if (isModelNotFound) {
break;
}
if (isRetriable && attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
continue;
}
break;
}
}
}
throw lastError || new Error("Qwen3-VL 调用失败");
}