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