feat: improve replacement text editing workflow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-28 23:02:21 +08:00
parent dc5ec5f87a
commit 769609ba26
4 changed files with 499 additions and 25 deletions

View File

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

View File

@ -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}
</div>
{card.passthrough ? (
<div className="absolute left-3 top-12 rounded-full border border-cyan-300/35 bg-cyan-500/[0.14] px-2.5 py-1 text-[11px] text-cyan-100">
<div className="absolute left-3 top-12 rounded-full border border-cyan-200/70 bg-cyan-500/85 px-2.5 py-1 text-[11px] text-white shadow-[0_8px_24px_rgba(6,182,212,0.28)]">
不处理直出
</div>
) : null}
@ -6367,24 +6390,22 @@ export function AgentGenerationWorkspaceDarkV2({
>
当前页改字
</button>
{!card.passthrough ? (
<button
type="button"
disabled={isAnyGenerationRunning}
onClick={() => {
setReplacementRerunTarget({
open: true,
modeType: "detail_replacement",
cardId: card.id,
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
});
setReplacementRerunPrompt("");
}}
className="rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20 disabled:cursor-not-allowed disabled:opacity-40"
>
重新生图
</button>
) : null}
<button
type="button"
disabled={isAnyGenerationRunning}
onClick={() => {
setReplacementRerunTarget({
open: true,
modeType: "detail_replacement",
cardId: card.id,
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
});
setReplacementRerunPrompt("");
}}
className="rounded-full border border-white/10 bg-black/55 px-3 py-1 text-[11px] text-white transition-colors hover:border-white/20 disabled:cursor-not-allowed disabled:opacity-40"
>
重新生图
</button>
</div>
</div>
) : (
@ -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);

View File

@ -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<AgentImageTextEditItem[]>([]);
@ -58,6 +80,20 @@ export function AgentImageTextEditDialog({
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [detectedSourceUrl, setDetectedSourceUrl] = useState<string | null>(null);
const [suggestionState, setSuggestionState] = useState<Record<string, SuggestionStateItem>>({});
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({
</div>
) : null}
{copyContextSummary ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
AI 文案建议会优先参考:{copyContextSummary}
</div>
) : null}
{isDetectingText ? (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<Loader2 className="h-4 w-4 animate-spin" />
@ -327,6 +445,70 @@ export function AgentImageTextEditDialog({
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
placeholder="输入替换后的文字"
/>
<div className="space-y-3 rounded-xl bg-slate-50/80 p-3 dark:bg-zinc-900/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-slate-500 dark:text-zinc-400">
一键 AI 文案
{suggestionState[item.id]?.model
? ` · ${suggestionState[item.id]?.model}`
: ""}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-8"
disabled={suggestionState[item.id]?.isLoading || isApplyingTextEdit}
onClick={() => void handleGenerateSuggestions(item)}
>
{suggestionState[item.id]?.isLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-2 h-3.5 w-3.5" />
)}
{suggestionState[item.id]?.suggestions?.length ? "换一批" : "生成 5 条"}
</Button>
</div>
{suggestionState[item.id]?.error ? (
<div className="rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500">
{suggestionState[item.id]?.error}
</div>
) : null}
{suggestionState[item.id]?.isLoading ? (
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在结合图片和人群信息生成文案建议...
</div>
) : null}
{suggestionState[item.id]?.suggestions?.length ? (
<div className="space-y-2">
{suggestionState[item.id].suggestions.map((suggestion, suggestionIndex) => (
<button
key={`${item.id}-suggestion-${suggestionIndex}`}
type="button"
onClick={() => handleApplySuggestion(item.id, suggestion.text)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-left transition-colors hover:border-emerald-300 hover:bg-emerald-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-emerald-700 dark:hover:bg-emerald-950/20"
>
<div className="text-sm font-medium text-slate-800 dark:text-zinc-100">
{suggestion.text}
</div>
{suggestion.reason ? (
<div className="mt-1 text-xs text-slate-500 dark:text-zinc-400">
{suggestion.reason}
</div>
) : null}
</button>
))}
</div>
) : !suggestionState[item.id]?.isLoading ? (
<div className="text-xs leading-5 text-slate-500 dark:text-zinc-400">
会生成 5 条字数接近、语气更生活化的候选文案,点击即可替换到当前输入框。
</div>
) : null}
</div>
</div>
))}
@ -375,7 +557,7 @@ export function AgentImageTextEditDialog({
) : (
<Type className="mr-2 h-4 w-4" />
)}
确认替换
确定生图
</Button>
</div>
</div>

View File

@ -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<ApiResponse<{ suggestions: CanvasTextSuggestionItem[]; model?: string }>> {
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;