|
|
|
@@ -1,8 +1,5 @@
|
|
|
|
|
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
|
|
|
|
|
|
|
|
|
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
|
|
|
|
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
|
|
|
|
|
|
|
|
|
type AbortSignalConstructorWithAny = typeof AbortSignal & {
|
|
|
|
|
any?: (signals: AbortSignal[]) => AbortSignal;
|
|
|
|
|
};
|
|
|
|
@@ -110,11 +107,45 @@ export interface ComplianceCheck {
|
|
|
|
|
allow_video_generation: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findJsonSlice(raw: string): string {
|
|
|
|
|
const start = raw.search(/[\[{]/);
|
|
|
|
|
if (start < 0) return raw;
|
|
|
|
|
|
|
|
|
|
const stack: string[] = [];
|
|
|
|
|
let inString = false;
|
|
|
|
|
let escaped = false;
|
|
|
|
|
|
|
|
|
|
for (let index = start; index < raw.length; index += 1) {
|
|
|
|
|
const char = raw[index];
|
|
|
|
|
|
|
|
|
|
if (inString) {
|
|
|
|
|
if (escaped) {
|
|
|
|
|
escaped = false;
|
|
|
|
|
} else if (char === "\\") {
|
|
|
|
|
escaped = true;
|
|
|
|
|
} else if (char === "\"") {
|
|
|
|
|
inString = false;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (char === "\"") {
|
|
|
|
|
inString = true;
|
|
|
|
|
} else if (char === "{" || char === "[") {
|
|
|
|
|
stack.push(char === "{" ? "}" : "]");
|
|
|
|
|
} else if (char === "}" || char === "]") {
|
|
|
|
|
if (stack.pop() !== char) break;
|
|
|
|
|
if (stack.length === 0) return raw.slice(start, index + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return raw.slice(start);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractJson(text: string): unknown {
|
|
|
|
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
|
|
|
const raw = fenced ? fenced[1].trim() : text.trim();
|
|
|
|
|
const start = raw.search(/[[{]/);
|
|
|
|
|
const slice = start >= 0 ? raw.slice(start) : raw;
|
|
|
|
|
const slice = findJsonSlice(raw);
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(slice);
|
|
|
|
|
} catch {
|
|
|
|
@@ -122,9 +153,16 @@ function extractJson(text: string): unknown {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ChatContent =
|
|
|
|
|
| string
|
|
|
|
|
| Array<
|
|
|
|
|
| { type: "image_url"; image_url: { url: string } }
|
|
|
|
|
| { type: "text"; text: string }
|
|
|
|
|
>;
|
|
|
|
|
|
|
|
|
|
interface ChatMessage {
|
|
|
|
|
role: "system" | "user";
|
|
|
|
|
content: string;
|
|
|
|
|
content: ChatContent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 3;
|
|
|
|
@@ -171,43 +209,32 @@ async function chat(
|
|
|
|
|
userContent: string,
|
|
|
|
|
options?: { model?: string; signal?: AbortSignal },
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
|
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
|
return retryOnTransient(async () => {
|
|
|
|
|
const messages: ChatMessage[] = [
|
|
|
|
|
{ role: "system", content: systemPrompt },
|
|
|
|
|
{ role: "user", content: userContent },
|
|
|
|
|
];
|
|
|
|
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
|
|
|
|
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
|
|
|
|
const body: Record<string, unknown> = { messages, stream: false, temperature: 0.4 };
|
|
|
|
|
if (options?.model) body.model = options.model;
|
|
|
|
|
|
|
|
|
|
for (const model of candidateModels) {
|
|
|
|
|
try {
|
|
|
|
|
return await retryOnTransient(async () => {
|
|
|
|
|
const messages: ChatMessage[] = [
|
|
|
|
|
{ role: "system", content: systemPrompt },
|
|
|
|
|
{ role: "user", content: userContent },
|
|
|
|
|
];
|
|
|
|
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
|
|
|
|
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
|
|
|
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAuthHeaders(),
|
|
|
|
|
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
|
|
|
|
|
signal: combinedSignal,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errBody = await res.text().catch(() => "");
|
|
|
|
|
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await res.json();
|
|
|
|
|
const content: string =
|
|
|
|
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
|
|
|
|
if (!content) throw new Error("模型未返回有效内容");
|
|
|
|
|
return content;
|
|
|
|
|
}, options?.signal);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
|
|
|
if (options?.signal?.aborted) throw lastError;
|
|
|
|
|
// If user pinned a specific model, don't fall back to others
|
|
|
|
|
if (options?.model) throw lastError;
|
|
|
|
|
// Try next model in fallback chain
|
|
|
|
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAuthHeaders(),
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
signal: combinedSignal,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errBody = await res.text().catch(() => "");
|
|
|
|
|
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw lastError ?? new Error("所有候选模型均不可用");
|
|
|
|
|
const payload = await res.json();
|
|
|
|
|
const content: string =
|
|
|
|
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
|
|
|
|
if (!content) throw new Error("模型未返回有效内容");
|
|
|
|
|
return content;
|
|
|
|
|
}, options?.signal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function visionChat(
|
|
|
|
@@ -216,50 +243,36 @@ async function visionChat(
|
|
|
|
|
imageUrls: string[],
|
|
|
|
|
signal?: AbortSignal,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const content = [
|
|
|
|
|
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
|
|
|
|
|
const content: ChatContent = [
|
|
|
|
|
...imageUrls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
|
|
|
|
|
{ type: "text", text },
|
|
|
|
|
];
|
|
|
|
|
const messages = [
|
|
|
|
|
{ role: "system", content: systemPrompt },
|
|
|
|
|
{ role: "user", content },
|
|
|
|
|
];
|
|
|
|
|
] satisfies ChatMessage[];
|
|
|
|
|
|
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
|
for (const model of VISION_MODELS) {
|
|
|
|
|
return retryOnTransient(async () => {
|
|
|
|
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
|
|
|
|
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
|
|
|
|
try {
|
|
|
|
|
const out = await retryOnTransient(async () => {
|
|
|
|
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAuthHeaders(),
|
|
|
|
|
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
|
|
|
|
|
signal: combinedSignal,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errBody = await res.text().catch(() => "");
|
|
|
|
|
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
|
|
|
|
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await res.json();
|
|
|
|
|
const result: string =
|
|
|
|
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
|
|
|
|
if (!result) throw new Error("图片理解未返回有效内容");
|
|
|
|
|
return result;
|
|
|
|
|
}, signal);
|
|
|
|
|
return out;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
|
|
|
if (signal?.aborted) throw lastError;
|
|
|
|
|
// Continue trying next vision model on transient failures, image format errors, or upstream errors
|
|
|
|
|
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
|
|
|
|
|
if (lastError.message.includes("图片理解调用失败")) continue;
|
|
|
|
|
if (isTransientError(lastError)) continue;
|
|
|
|
|
throw lastError;
|
|
|
|
|
|
|
|
|
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAuthHeaders(),
|
|
|
|
|
body: JSON.stringify({ messages, stream: false, temperature: 0.3 }),
|
|
|
|
|
signal: combinedSignal,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errBody = await res.text().catch(() => "");
|
|
|
|
|
if (errBody.includes("image format")) throw new Error("图片格式不受支持,请更换图片后重试");
|
|
|
|
|
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
|
|
|
|
|
const payload = await res.json();
|
|
|
|
|
const result: string =
|
|
|
|
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
|
|
|
|
if (!result) throw new Error("图片理解未返回有效内容");
|
|
|
|
|
return result;
|
|
|
|
|
}, signal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
|
|
|
|