Your Name 769609ba26
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve replacement text editing workflow
2026-04-28 23:02:21 +08:00

236 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
);
}
}