174 lines
4.9 KiB
TypeScript
174 lines
4.9 KiB
TypeScript
import "server-only";
|
||
|
||
import { generateText } from "ai";
|
||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||
|
||
const QWEN_VL_MODEL_MATCHER =
|
||
/(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
|
||
|
||
function sanitizeAppearanceSummary(value: unknown, maxLength = 220) {
|
||
return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
||
}
|
||
|
||
function extractJsonObjectText(text: string): string | null {
|
||
const normalized = String(text || "").trim();
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
if (fencedMatch?.[1]) {
|
||
const fencedText = fencedMatch[1].trim();
|
||
if (fencedText.startsWith("{") && fencedText.endsWith("}")) {
|
||
return fencedText;
|
||
}
|
||
}
|
||
|
||
if (normalized.startsWith("{") && normalized.endsWith("}")) {
|
||
return normalized;
|
||
}
|
||
|
||
const firstBraceIndex = normalized.indexOf("{");
|
||
const lastBraceIndex = normalized.lastIndexOf("}");
|
||
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
||
return normalized.slice(firstBraceIndex, lastBraceIndex + 1);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function parseAppearanceSummary(text: string) {
|
||
const jsonText = extractJsonObjectText(text);
|
||
if (jsonText) {
|
||
try {
|
||
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
|
||
const summary = sanitizeAppearanceSummary(
|
||
parsed.appearanceSummary || parsed.summary || parsed.description
|
||
);
|
||
if (summary) {
|
||
return summary;
|
||
}
|
||
} catch (error) {
|
||
console.warn(
|
||
"[Workflow Material Appearance] Failed to parse JSON response:",
|
||
error
|
||
);
|
||
}
|
||
}
|
||
|
||
return sanitizeAppearanceSummary(
|
||
String(text || "")
|
||
.replace(/```(?:json)?/gi, "")
|
||
.replace(/```/g, "")
|
||
);
|
||
}
|
||
|
||
function buildAppearancePrompt() {
|
||
return [
|
||
"你是电商商品白底图识别器。",
|
||
"请只基于商品本体,识别并描述产品的外观结构和颜色,用于后续生图时保持产品结构不要改变。",
|
||
"描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色与颜色分布。",
|
||
"不要分析或描述材质、纹理、表面工艺、木纹、金属感、布艺感,也不要描述背景、光影、拍摄角度、水印、文案,更不要输出营销形容词。",
|
||
'只输出 JSON:{"appearanceSummary":"..."}',
|
||
"appearanceSummary 必须是中文,长度 60 到 120 字,具体、克制、可直接作为生图约束。",
|
||
].join("\n");
|
||
}
|
||
|
||
async function analyzeWithConfiguredQwenVisionModel(params: {
|
||
imageUrl: string;
|
||
prompt: string;
|
||
}) {
|
||
const configuredModels = await getEnabledAiModelsWithProvider().catch((error) => {
|
||
console.warn(
|
||
"[Workflow Material Appearance] Failed to load configured models:",
|
||
error
|
||
);
|
||
return [];
|
||
});
|
||
const configuredQwenVisionModel = configuredModels.find(
|
||
({ model }) =>
|
||
model.supportsVision &&
|
||
!model.isSubModel &&
|
||
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`)
|
||
);
|
||
|
||
if (!configuredQwenVisionModel) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const languageModel = await getLanguageModelById(
|
||
configuredQwenVisionModel.model.id
|
||
);
|
||
if (!languageModel) {
|
||
return null;
|
||
}
|
||
|
||
const { text } = await generateText({
|
||
model: languageModel,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: params.prompt,
|
||
},
|
||
{
|
||
type: "image",
|
||
image: params.imageUrl,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
temperature: 0,
|
||
maxTokens: 220,
|
||
abortSignal: AbortSignal.timeout(25_000),
|
||
} as any);
|
||
|
||
return parseAppearanceSummary(text);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[Workflow Material Appearance] Configured Qwen-VL analysis failed:",
|
||
error
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function analyzeWorkflowMaterialAppearanceFromImage(imageUrl: string) {
|
||
const normalizedUrl = String(imageUrl || "").trim();
|
||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||
return null;
|
||
}
|
||
|
||
const prompt = buildAppearancePrompt();
|
||
const configuredSummary = await analyzeWithConfiguredQwenVisionModel({
|
||
imageUrl: normalizedUrl,
|
||
prompt,
|
||
});
|
||
if (configuredSummary) {
|
||
return configuredSummary;
|
||
}
|
||
|
||
try {
|
||
const result = await callQwen3Vl({
|
||
imageUrl: normalizedUrl,
|
||
prompt,
|
||
temperature: 0,
|
||
maxTokens: 220,
|
||
timeoutMs: 25_000,
|
||
retries: 1,
|
||
});
|
||
return parseAppearanceSummary(result.text);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[Workflow Material Appearance] Fallback Qwen3-VL analysis failed:",
|
||
error
|
||
);
|
||
return null;
|
||
}
|
||
}
|