236 lines
7.9 KiB
TypeScript
236 lines
7.9 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 120;
|
||
|
||
const requestSchema = z.object({
|
||
imageUrl: z.string().min(1, "图片地址不能为空"),
|
||
originalText: z.string().min(1, "原文不能为空"),
|
||
currentText: z.string().optional(),
|
||
title: z.string().optional(),
|
||
pageTexts: z.array(z.string()).optional(),
|
||
audience: z.string().optional(),
|
||
usageScenario: z.string().optional(),
|
||
coreNeed: z.string().optional(),
|
||
productName: z.string().optional(),
|
||
workflowSummary: z.string().optional(),
|
||
workflowPrompt: z.string().optional(),
|
||
userPrompt: z.string().optional(),
|
||
presetName: z.string().optional(),
|
||
});
|
||
|
||
interface SuggestionItem {
|
||
text: string;
|
||
reason?: string;
|
||
}
|
||
|
||
function parseJsonText(raw: string): any | null {
|
||
if (!raw) return null;
|
||
|
||
const trimmed = raw.trim();
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
// continue
|
||
}
|
||
|
||
const markdownJsonMatch = trimmed.match(/```json\s*([\s\S]*?)\s*```/i);
|
||
if (markdownJsonMatch?.[1]) {
|
||
try {
|
||
return JSON.parse(markdownJsonMatch[1]);
|
||
} catch {
|
||
// continue
|
||
}
|
||
}
|
||
|
||
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
|
||
if (objectMatch?.[0]) {
|
||
try {
|
||
return JSON.parse(objectMatch[0]);
|
||
} catch {
|
||
// continue
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function normalizeText(value?: string | null, maxLength = 240) {
|
||
return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
||
}
|
||
|
||
function countMeaningfulChars(value: string) {
|
||
return normalizeText(value, 500).replace(/\s+/g, "").length;
|
||
}
|
||
|
||
function uniqueStrings(values: string[]) {
|
||
return Array.from(new Set(values.map((item) => normalizeText(item, 240)).filter(Boolean)));
|
||
}
|
||
|
||
function sanitizeSuggestions(items: unknown[], originalText: string): SuggestionItem[] {
|
||
const seen = new Set<string>();
|
||
const normalizedOriginal = normalizeText(originalText, 240);
|
||
const suggestions: SuggestionItem[] = [];
|
||
|
||
for (const entry of items) {
|
||
const raw =
|
||
entry && typeof entry === "object" && !Array.isArray(entry)
|
||
? (entry as Record<string, unknown>)
|
||
: null;
|
||
const text = normalizeText(raw?.text as string, 120);
|
||
if (!text || seen.has(text)) {
|
||
continue;
|
||
}
|
||
seen.add(text);
|
||
suggestions.push({
|
||
text,
|
||
reason: normalizeText((raw?.reason as string) || "", 60) || undefined,
|
||
});
|
||
if (suggestions.length >= 5) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (suggestions.length === 0 && normalizedOriginal) {
|
||
return [{ text: normalizedOriginal, reason: "原文保留" }];
|
||
}
|
||
|
||
return suggestions;
|
||
}
|
||
|
||
function buildSuggestionPrompt(params: {
|
||
originalText: string;
|
||
currentText: string;
|
||
title: string;
|
||
pageTexts: string[];
|
||
audience: string;
|
||
usageScenario: string;
|
||
coreNeed: string;
|
||
productName: string;
|
||
workflowSummary: string;
|
||
workflowPrompt: string;
|
||
userPrompt: string;
|
||
presetName: string;
|
||
}) {
|
||
const baseText = params.currentText || params.originalText;
|
||
const targetLength = Math.max(2, countMeaningfulChars(baseText));
|
||
const tolerance = Math.max(2, Math.min(8, Math.round(targetLength * 0.35)));
|
||
const minLength = Math.max(2, targetLength - tolerance);
|
||
const maxLength = targetLength + tolerance;
|
||
const pageTexts = uniqueStrings(params.pageTexts).slice(0, 12);
|
||
|
||
return [
|
||
"你是电商营销设计里的文案微调助手,要根据图片内容和上下文,为图片中的单条文案生成可直接替换的备选句。",
|
||
"请特别注意:",
|
||
"1. 输出 5 条中文备选文案。",
|
||
"2. 文案字数必须接近原文,尽量控制在接近范围内。",
|
||
`3. 当前目标文案长度参考:约 ${targetLength} 字,可接受范围 ${minLength}-${maxLength} 字(按不含空格的视觉字数理解)。`,
|
||
"4. 风格要让用户向往和代入,少一些直接叫卖式表达,少用专业术语,多用大白话,多写体感和生活感。",
|
||
"5. 要结合给定的人群、场景、核心需求和整页语境,但不要脱离原文所在层级。",
|
||
"6. 如果原文看起来更像品牌名、型号、编号、价格或固定信息,就不要强行写成夸张广告词,应给更稳妥、接近原意的版本。",
|
||
"7. 不要输出解释性长文,不要加引号、编号、emoji。",
|
||
"8. 只返回 JSON,格式必须是:{\"suggestions\":[{\"text\":\"...\",\"reason\":\"...\"}]}。",
|
||
"",
|
||
`当前模块:${params.presetName || "详情替换结果图改字"}`,
|
||
`图片区域标题:${params.title || "未命名结果图"}`,
|
||
`待改原文:${params.originalText}`,
|
||
params.currentText && params.currentText !== params.originalText
|
||
? `当前输入框内容:${params.currentText}`
|
||
: "",
|
||
params.productName ? `产品名称:${params.productName}` : "",
|
||
params.audience ? `目标人群:${params.audience}` : "",
|
||
params.usageScenario ? `使用场景:${params.usageScenario}` : "",
|
||
params.coreNeed ? `核心需求:${params.coreNeed}` : "",
|
||
params.userPrompt ? `用户补充提示词:${params.userPrompt}` : "",
|
||
params.workflowSummary ? `工作流摘要:${params.workflowSummary}` : "",
|
||
params.workflowPrompt ? `工作流风格要求:${params.workflowPrompt}` : "",
|
||
pageTexts.length > 0 ? `当前整页其它文案参考:${pageTexts.join(" / ")}` : "",
|
||
"",
|
||
"请输出 5 条能直接替换这条文字的候选文案,每条都要贴合图片语境。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const body = await request.json();
|
||
const parsed = requestSchema.safeParse(body);
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ success: false, error: parsed.error.errors[0]?.message || "参数错误" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const imageUrl = normalizeText(parsed.data.imageUrl, 2000);
|
||
if (!/^https?:\/\//i.test(imageUrl)) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "仅支持 URL 图片,不允许 base64 传输" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const originalText = normalizeText(parsed.data.originalText, 120);
|
||
const currentText = normalizeText(parsed.data.currentText, 120);
|
||
const prompt = buildSuggestionPrompt({
|
||
originalText,
|
||
currentText,
|
||
title: normalizeText(parsed.data.title, 120),
|
||
pageTexts: Array.isArray(parsed.data.pageTexts) ? parsed.data.pageTexts : [],
|
||
audience: normalizeText(parsed.data.audience, 120),
|
||
usageScenario: normalizeText(parsed.data.usageScenario, 120),
|
||
coreNeed: normalizeText(parsed.data.coreNeed, 120),
|
||
productName: normalizeText(parsed.data.productName, 120),
|
||
workflowSummary: normalizeText(parsed.data.workflowSummary, 500),
|
||
workflowPrompt: normalizeText(parsed.data.workflowPrompt, 800),
|
||
userPrompt: normalizeText(parsed.data.userPrompt, 300),
|
||
presetName: normalizeText(parsed.data.presetName, 80),
|
||
});
|
||
|
||
const result = await callQwen3Vl({
|
||
imageUrl,
|
||
prompt,
|
||
temperature: 0.5,
|
||
maxTokens: 900,
|
||
timeoutMs: 50000,
|
||
retries: 1,
|
||
});
|
||
|
||
const parsedJson = parseJsonText(result.text || "");
|
||
const suggestions = sanitizeSuggestions(parsedJson?.suggestions || [], originalText);
|
||
|
||
if (suggestions.length === 0) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "未生成可用文案建议" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
suggestions,
|
||
model: result.model,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("[Canvas Text Copy Suggestions] Error:", error);
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "文案建议生成失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|