diff --git a/app/api/canvas/text-copy-suggestions/route.ts b/app/api/canvas/text-copy-suggestions/route.ts new file mode 100644 index 000000000..8e0624685 --- /dev/null +++ b/app/api/canvas/text-copy-suggestions/route.ts @@ -0,0 +1,235 @@ +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 } + ); + } +} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index d3e4310d9..4a9d09dbd 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -890,7 +890,8 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) { case "passthrough": return { label: "不处理直出", - className: "border-cyan-400/30 bg-cyan-500/[0.10] text-cyan-200", + className: + "border-cyan-200/70 bg-cyan-500/85 text-white shadow-[0_8px_24px_rgba(6,182,212,0.28)]", }; case "fallback": return { @@ -1788,6 +1789,28 @@ export function AgentGenerationWorkspaceDarkV2({ detailReplacementState.generationMode ); const detailGenerationBrief = sanitizeAgentDetailBrief(detailReplacementState.brief); + const replacementTextSuggestionContext = useMemo(() => { + const latestWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; + return { + audience: detailGenerationBrief.targetAudience || null, + usageScenario: detailGenerationBrief.usageScenario || null, + coreNeed: detailGenerationBrief.coreNeed || null, + productName: detailGenerationBrief.productName || null, + workflowSummary: latestWorkflowContext?.summaryText || null, + workflowPrompt: latestWorkflowContext?.promptText || null, + userPrompt: input.trim() || null, + presetName: selectedAgentPreset?.name || null, + }; + }, [ + detailGenerationBrief.coreNeed, + detailGenerationBrief.productName, + detailGenerationBrief.targetAudience, + detailGenerationBrief.usageScenario, + getLatestWorkflowContext, + input, + selectedAgentPreset?.name, + workflowContext, + ]); const detailGenerationSlots = Array.isArray(detailReplacementState.slots) && detailReplacementState.slots.length === AGENT_DETAIL_SLOT_CONFIGS.length @@ -6237,7 +6260,7 @@ export function AgentGenerationWorkspaceDarkV2({ 参考图 {index + 1} {card.passthrough ? ( -
+
不处理直出
) : null} @@ -6367,24 +6390,22 @@ export function AgentGenerationWorkspaceDarkV2({ > 当前页改字 - {!card.passthrough ? ( - - ) : null} +
) : ( @@ -6409,7 +6430,7 @@ export function AgentGenerationWorkspaceDarkV2({ : card.status === "queued" ? "这一屏已加入当前批量队列,前面的屏完成后会自动继续。" : card.status === "passthrough" - ? "当前参考图未检测到可替换产品,本屏直接沿用参考图展示。" + ? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。" : card.status === "fallback" ? "当前参考图替换失败,本屏先沿用参考图展示。" : "已生成,可直接在当前页改字;如果不满意,也可以补充提示词重新生图。" @@ -6912,7 +6933,7 @@ export function AgentGenerationWorkspaceDarkV2({ : card.status === "queued" ? "这一张已加入当前批量队列,前面的图完成后会自动继续。" : card.status === "passthrough" - ? "当前参考图未检测到可替换产品,这一张直接沿用参考图展示。" + ? "当前参考图未检测到可替换产品,这一张先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。" : card.status === "fallback" ? "当前参考图替换失败,这一张先沿用参考图展示。" : "已生成,可直接在当前页改字;如果不满意,也可以补充提示词重新生图。" @@ -7184,6 +7205,7 @@ export function AgentGenerationWorkspaceDarkV2({ open={Boolean(replacementTextEditTarget?.open)} imageUrl={replacementTextEditTarget?.imageUrl} title={replacementTextEditTarget?.title} + copySuggestionContext={replacementTextSuggestionContext} onOpenChange={(open) => { if (!open) { setReplacementTextEditTarget(null); diff --git a/components/agents/AgentImageTextEditDialog.tsx b/components/agents/AgentImageTextEditDialog.tsx index 961659b7b..ecab22f33 100644 --- a/components/agents/AgentImageTextEditDialog.tsx +++ b/components/agents/AgentImageTextEditDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Loader2, RefreshCw, Trash2, Type, X } from "lucide-react"; +import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react"; import { toast } from "sonner"; import { Dialog, @@ -12,7 +12,11 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { imageGenerationService, type CanvasTextEditPayloadItem } from "@/services/canvas"; +import { + imageGenerationService, + type CanvasTextEditPayloadItem, + type CanvasTextSuggestionItem, +} from "@/services/canvas"; interface AgentImageTextEditDialogProps { open: boolean; @@ -20,6 +24,16 @@ interface AgentImageTextEditDialogProps { title?: string | null; onOpenChange: (open: boolean) => void; onApplySuccess?: (result: { originalUrl: string; thumbnailUrl?: string | null; model?: string | null }) => void; + copySuggestionContext?: { + audience?: string | null; + usageScenario?: string | null; + coreNeed?: string | null; + productName?: string | null; + workflowSummary?: string | null; + workflowPrompt?: string | null; + userPrompt?: string | null; + presetName?: string | null; + } | null; } interface AgentImageTextEditItem { @@ -34,6 +48,13 @@ interface DeletedTextItem { originalText: string; } +interface SuggestionStateItem { + isLoading: boolean; + suggestions: CanvasTextSuggestionItem[]; + error?: string | null; + model?: string | null; +} + function isHttpImageUrl(value?: string | null) { if (!value) return false; try { @@ -50,6 +71,7 @@ export function AgentImageTextEditDialog({ title, onOpenChange, onApplySuccess, + copySuggestionContext = null, }: AgentImageTextEditDialogProps) { const normalizedImageUrl = useMemo(() => String(imageUrl || "").trim(), [imageUrl]); const [textEditItems, setTextEditItems] = useState([]); @@ -58,6 +80,20 @@ export function AgentImageTextEditDialog({ const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [textEditError, setTextEditError] = useState(null); const [detectedSourceUrl, setDetectedSourceUrl] = useState(null); + const [suggestionState, setSuggestionState] = useState>({}); + + const copyContextSummary = useMemo(() => { + const chips = [ + copySuggestionContext?.audience ? `人群:${copySuggestionContext.audience}` : "", + copySuggestionContext?.usageScenario ? `场景:${copySuggestionContext.usageScenario}` : "", + copySuggestionContext?.coreNeed ? `需求:${copySuggestionContext.coreNeed}` : "", + ].filter(Boolean); + return chips.join(" / "); + }, [ + copySuggestionContext?.audience, + copySuggestionContext?.coreNeed, + copySuggestionContext?.usageScenario, + ]); const detectTextItems = useCallback( async (forceRefresh = false) => { @@ -142,6 +178,7 @@ export function AgentImageTextEditDialog({ setTextEditItems([]); setDeletedTextItems([]); + setSuggestionState({}); void detectTextItems(true); }, [detectTextItems, detectedSourceUrl, normalizedImageUrl, open, textEditItems.length]); @@ -167,6 +204,81 @@ export function AgentImageTextEditDialog({ }); }, []); + const handleGenerateSuggestions = useCallback( + async (item: AgentImageTextEditItem) => { + if (!normalizedImageUrl) { + toast.error("当前图片地址无效"); + return; + } + + if (!isHttpImageUrl(normalizedImageUrl)) { + toast.error("仅支持 URL 图片,不允许 base64 传输"); + return; + } + + setSuggestionState((current) => ({ + ...current, + [item.id]: { + isLoading: true, + suggestions: current[item.id]?.suggestions || [], + error: null, + model: current[item.id]?.model || null, + }, + })); + + try { + const result = await imageGenerationService.generateCanvasTextSuggestions({ + imageUrl: normalizedImageUrl, + originalText: item.originalText, + currentText: item.editedText, + title: title || undefined, + pageTexts: textEditItems.map((entry) => entry.editedText.trim() || entry.originalText.trim()), + audience: copySuggestionContext?.audience || undefined, + usageScenario: copySuggestionContext?.usageScenario || undefined, + coreNeed: copySuggestionContext?.coreNeed || undefined, + productName: copySuggestionContext?.productName || undefined, + workflowSummary: copySuggestionContext?.workflowSummary || undefined, + workflowPrompt: copySuggestionContext?.workflowPrompt || undefined, + userPrompt: copySuggestionContext?.userPrompt || undefined, + presetName: copySuggestionContext?.presetName || undefined, + }); + + if (!result.success || !result.data?.suggestions?.length) { + throw new Error(result.error || "未生成可用文案建议"); + } + + setSuggestionState((current) => ({ + ...current, + [item.id]: { + isLoading: false, + suggestions: result.data?.suggestions || [], + error: null, + model: result.data?.model || null, + }, + })); + toast.success("已生成 5 条 AI 文案建议"); + } catch (error) { + const message = error instanceof Error ? error.message : "文案建议生成失败"; + setSuggestionState((current) => ({ + ...current, + [item.id]: { + isLoading: false, + suggestions: current[item.id]?.suggestions || [], + error: message, + model: current[item.id]?.model || null, + }, + })); + toast.error(message); + } + }, + [copySuggestionContext, normalizedImageUrl, textEditItems, title] + ); + + const handleApplySuggestion = useCallback((id: string, value: string) => { + handleTextItemChange(id, value); + toast.success("已套用推荐文案"); + }, [handleTextItemChange]); + const handleApplyTextEdit = useCallback(async () => { if (!normalizedImageUrl) { toast.error("当前图片地址无效"); @@ -279,6 +391,12 @@ export function AgentImageTextEditDialog({ ) : null} + {copyContextSummary ? ( +
+ AI 文案建议会优先参考:{copyContextSummary} +
+ ) : null} + {isDetectingText ? (
@@ -327,6 +445,70 @@ export function AgentImageTextEditDialog({ onChange={(event) => handleTextItemChange(item.id, event.target.value)} placeholder="输入替换后的文字" /> +
+
+
+ 一键 AI 文案 + {suggestionState[item.id]?.model + ? ` · ${suggestionState[item.id]?.model}` + : ""} +
+ +
+ + {suggestionState[item.id]?.error ? ( +
+ {suggestionState[item.id]?.error} +
+ ) : null} + + {suggestionState[item.id]?.isLoading ? ( +
+ + 正在结合图片和人群信息生成文案建议... +
+ ) : null} + + {suggestionState[item.id]?.suggestions?.length ? ( +
+ {suggestionState[item.id].suggestions.map((suggestion, suggestionIndex) => ( + + ))} +
+ ) : !suggestionState[item.id]?.isLoading ? ( +
+ 会生成 5 条字数接近、语气更生活化的候选文案,点击即可替换到当前输入框。 +
+ ) : null} +
))} @@ -375,7 +557,7 @@ export function AgentImageTextEditDialog({ ) : ( )} - 确认替换 + 确定生图 diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 145ffb332..3780f4338 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -46,6 +46,11 @@ export interface CanvasTextEditPayloadItem { action?: 'replace' | 'delete'; } +export interface CanvasTextSuggestionItem { + text: string; + reason?: string; +} + export type WorkflowAnalysisMode = | "video-analysis" | "character-scene-extraction" @@ -673,6 +678,36 @@ export const imageGenerationService = { } }, + // 生成文字改写建议 + async generateCanvasTextSuggestions(params: { + imageUrl: string; + originalText: string; + currentText?: string; + title?: string; + pageTexts?: string[]; + audience?: string; + usageScenario?: string; + coreNeed?: string; + productName?: string; + workflowSummary?: string; + workflowPrompt?: string; + userPrompt?: string; + presetName?: string; + }): Promise> { + try { + const response = await fetch('/api/canvas/text-copy-suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = await response.json(); + return data; + } catch (error) { + console.error('[Canvas] 文案建议生成失败:', error); + return { success: false, error: '文案建议生成失败' }; + } + }, + // 应用文字替换并生成新图 async editCanvasText(params: { imageUrl: string;