feat: improve replacement text editing workflow
This commit is contained in:
parent
dc5ec5f87a
commit
769609ba26
235
app/api/canvas/text-copy-suggestions/route.ts
Normal file
235
app/api/canvas/text-copy-suggestions/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user