feat: persist canvas detail archives and extend detail metadata
This commit is contained in:
parent
2332a5d6c8
commit
81c1e955cb
163
app/api/canvas/[id]/detail-archives/route.ts
Normal file
163
app/api/canvas/[id]/detail-archives/route.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasDetailArchive, canvasProject } from "@/lib/db/schema";
|
||||
|
||||
type DetailArchiveBrief = {
|
||||
productSpu?: string;
|
||||
demandType?: string;
|
||||
productName?: string;
|
||||
targetAudience?: string;
|
||||
mainUser?: string;
|
||||
usageScenario?: string;
|
||||
triggerMoment?: string;
|
||||
coreNeed?: string;
|
||||
riskConcern?: string;
|
||||
expectedResult?: string;
|
||||
coreSellingPoint?: string;
|
||||
positioningStatement?: string;
|
||||
userVoice?: string;
|
||||
competitorReference?: string;
|
||||
priceBand?: string;
|
||||
brandTone?: string;
|
||||
bannedWords?: string;
|
||||
};
|
||||
|
||||
type DetailArchiveSlot = {
|
||||
url: string;
|
||||
name?: string;
|
||||
contentType?: string;
|
||||
} | null;
|
||||
|
||||
function sanitizeBrief(input: unknown): DetailArchiveBrief {
|
||||
const raw = (input || {}) as Record<string, unknown>;
|
||||
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
productSpu: normalize("productSpu"),
|
||||
demandType: normalize("demandType"),
|
||||
productName: normalize("productName"),
|
||||
targetAudience: normalize("targetAudience"),
|
||||
mainUser: normalize("mainUser"),
|
||||
usageScenario: normalize("usageScenario"),
|
||||
triggerMoment: normalize("triggerMoment"),
|
||||
coreNeed: normalize("coreNeed"),
|
||||
riskConcern: normalize("riskConcern"),
|
||||
expectedResult: normalize("expectedResult"),
|
||||
coreSellingPoint: normalize("coreSellingPoint"),
|
||||
positioningStatement: normalize("positioningStatement"),
|
||||
userVoice: normalize("userVoice"),
|
||||
competitorReference: normalize("competitorReference"),
|
||||
priceBand: normalize("priceBand"),
|
||||
brandTone: normalize("brandTone"),
|
||||
bannedWords: normalize("bannedWords"),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeSlots(input: unknown): DetailArchiveSlot[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.slice(0, 16).map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const raw = item as Record<string, unknown>;
|
||||
const url = String(raw.url || "").trim();
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
name: String(raw.name || "").trim() || undefined,
|
||||
contentType: String(raw.contentType || "").trim() || "image/jpeg",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureProjectOwnership(projectId: string, userId: string) {
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
|
||||
.limit(1);
|
||||
return Boolean(project);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: projectId } = await params;
|
||||
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
||||
if (!ownsProject) {
|
||||
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const archives = await db
|
||||
.select()
|
||||
.from(canvasDetailArchive)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasDetailArchive.projectId, projectId),
|
||||
eq(canvasDetailArchive.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasDetailArchive.createdAt))
|
||||
.limit(30);
|
||||
|
||||
return NextResponse.json({ success: true, data: archives });
|
||||
} catch (error) {
|
||||
console.error("[CanvasDetailArchive] GET failed:", error);
|
||||
return NextResponse.json({ success: false, error: "获取详情存档失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: projectId } = await params;
|
||||
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
||||
if (!ownsProject) {
|
||||
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const archiveRaw = (body?.archive || {}) as Record<string, unknown>;
|
||||
const title = String(archiveRaw.title || "").trim() || "AI详情项目";
|
||||
const prompt = String(archiveRaw.prompt || "").replace(/\s+/g, " ").trim();
|
||||
const summary = String(archiveRaw.summary || "").trim();
|
||||
const imageCount = Number(archiveRaw.imageCount);
|
||||
const coverUrl = String(archiveRaw.coverUrl || "").trim();
|
||||
|
||||
const brief = sanitizeBrief(archiveRaw.brief);
|
||||
const slots = sanitizeSlots(archiveRaw.slots);
|
||||
|
||||
const [saved] = await db
|
||||
.insert(canvasDetailArchive)
|
||||
.values({
|
||||
projectId,
|
||||
userId: session.user.id,
|
||||
title,
|
||||
prompt,
|
||||
brief,
|
||||
slots,
|
||||
summary: summary || null,
|
||||
imageCount: Number.isFinite(imageCount) ? Math.max(0, Math.round(imageCount)) : null,
|
||||
coverUrl: coverUrl || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, data: saved });
|
||||
} catch (error) {
|
||||
console.error("[CanvasDetailArchive] POST failed:", error);
|
||||
return NextResponse.json({ success: false, error: "保存详情存档失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,8 @@ type DetailPlanRequest = {
|
||||
modelImageUrl?: string;
|
||||
bundleImageUrls?: string[];
|
||||
brief?: {
|
||||
productSpu?: string;
|
||||
demandType?: string;
|
||||
productName?: string;
|
||||
targetAudience?: string;
|
||||
mainUser?: string;
|
||||
@ -38,6 +40,8 @@ type DetailPlanRequest = {
|
||||
};
|
||||
|
||||
type DetailBrief = {
|
||||
productSpu: string;
|
||||
demandType: string;
|
||||
productName: string;
|
||||
targetAudience: string;
|
||||
mainUser: string;
|
||||
@ -188,6 +192,8 @@ function normalizeDetailBrief(value: unknown): DetailBrief {
|
||||
const raw = (value || {}) as Record<string, unknown>;
|
||||
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
productSpu: normalize("productSpu"),
|
||||
demandType: normalize("demandType"),
|
||||
productName: normalize("productName"),
|
||||
targetAudience: normalize("targetAudience"),
|
||||
mainUser: normalize("mainUser"),
|
||||
@ -220,6 +226,8 @@ function buildBriefText(brief: DetailBrief, language: "zh" | "en") {
|
||||
const lines =
|
||||
language === "en"
|
||||
? [
|
||||
brief.productSpu ? `Product SPU: ${brief.productSpu}` : "",
|
||||
brief.demandType ? `Demand type: ${brief.demandType}` : "",
|
||||
brief.productName ? `Product: ${brief.productName}` : "",
|
||||
`Primary buyer audience: ${brief.targetAudience}`,
|
||||
brief.mainUser ? `Primary user: ${brief.mainUser}` : "",
|
||||
@ -237,6 +245,8 @@ function buildBriefText(brief: DetailBrief, language: "zh" | "en") {
|
||||
brief.bannedWords ? `Banned words: ${brief.bannedWords}` : "",
|
||||
]
|
||||
: [
|
||||
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
|
||||
brief.demandType ? `需求类型:${brief.demandType}` : "",
|
||||
brief.productName ? `产品:${brief.productName}` : "",
|
||||
`主购买人群:${brief.targetAudience}`,
|
||||
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
|
||||
|
||||
@ -285,6 +285,8 @@ interface CanvasChatSession {
|
||||
}
|
||||
|
||||
type DetailBriefForm = {
|
||||
productSpu: string;
|
||||
demandType: string;
|
||||
productName: string;
|
||||
targetAudience: string;
|
||||
mainUser: string;
|
||||
@ -382,6 +384,11 @@ type DetailPlanResponse = {
|
||||
|
||||
const DETAIL_TARGET_WIDTH = 790;
|
||||
const DETAIL_TARGET_HEIGHT = 1300;
|
||||
const DETAIL_REVIEW_COUNT = 3;
|
||||
const DETAIL_REVIEW_REGEN_COUNT = 2;
|
||||
const DETAIL_REVIEW_IMAGE_COUNT = 3;
|
||||
const DETAIL_REVIEW_IMAGE_WIDTH = 420;
|
||||
const DETAIL_REVIEW_IMAGE_HEIGHT = 420;
|
||||
const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [
|
||||
{ key: "product-1", label: "产品图1", role: "product" },
|
||||
{ key: "product-2", label: "产品图2", role: "product" },
|
||||
@ -401,6 +408,8 @@ const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
|
||||
slot.role === "bundle" ? index : -1
|
||||
).filter((index) => index >= 0);
|
||||
const DETAIL_FIELD_EXAMPLES: Record<keyof DetailBriefForm, string> = {
|
||||
productSpu: "示例:SPU-889901",
|
||||
demandType: "示例:清洁除尘 / 户外通勤 / 礼赠",
|
||||
productName: "示例:Buyi-AI 无线手持吸尘器",
|
||||
targetAudience: "示例:25-40岁养宠家庭、宝妈",
|
||||
mainUser: "示例:家庭主理人、租房上班族",
|
||||
@ -428,10 +437,6 @@ function getSessionStorageKey(canvasId: string): string {
|
||||
return `canvas-chat-sessions:${canvasId}`;
|
||||
}
|
||||
|
||||
function getDetailArchiveStorageKey(canvasId: string): string {
|
||||
return `canvas-detail-archives:${canvasId}`;
|
||||
}
|
||||
|
||||
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
|
||||
const firstUserMessage = messages.find((message) => message.role === "user");
|
||||
if (!firstUserMessage) return "";
|
||||
@ -452,6 +457,109 @@ function extractDetailFrameCount(text: string): number {
|
||||
return Math.min(12, Math.max(5, Math.round(value)));
|
||||
}
|
||||
|
||||
type DetailReviewPlan = {
|
||||
index: number;
|
||||
title: string;
|
||||
reviewText: string;
|
||||
imagePrompt: string;
|
||||
};
|
||||
|
||||
type DetailReviewGroup = {
|
||||
reviewText: string;
|
||||
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
|
||||
};
|
||||
|
||||
function splitDetailKeywords(value: string, limit = 3): string[] {
|
||||
const raw = String(value || "")
|
||||
.split(/[\n,,、;;|]/)
|
||||
.map((item) => item.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
if (raw.length > 0) return raw.slice(0, limit);
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildDetailReviewPlans(params: {
|
||||
brief: DetailBriefForm;
|
||||
summary?: string;
|
||||
language: "zh" | "en";
|
||||
count?: number;
|
||||
}): DetailReviewPlan[] {
|
||||
const count = Math.max(1, Math.min(5, Math.round(params.count || DETAIL_REVIEW_COUNT)));
|
||||
const audience = params.brief.targetAudience || (params.language === "en" ? "target users" : "目标用户");
|
||||
const scenario = params.brief.usageScenario || (params.language === "en" ? "daily life scenario" : "日常场景");
|
||||
const coreNeed = params.brief.coreNeed || (params.language === "en" ? "core need" : "核心需求");
|
||||
const productName = params.brief.productName || (params.language === "en" ? "the product" : "该产品");
|
||||
const expectedResult =
|
||||
params.brief.expectedResult || (params.language === "en" ? "clear and reliable improvement" : "效果明显且稳定");
|
||||
const riskConcern =
|
||||
params.brief.riskConcern || (params.language === "en" ? "quality and usability concerns" : "担心效果不稳定");
|
||||
const keywords = splitDetailKeywords(params.brief.coreSellingPoint, 3);
|
||||
const firstKeyword = keywords[0] || (params.language === "en" ? "stable performance" : "稳定好用");
|
||||
const secondKeyword = keywords[1] || (params.language === "en" ? "easy operation" : "上手简单");
|
||||
const thirdKeyword = keywords[2] || (params.language === "en" ? "value for money" : "性价比高");
|
||||
const summaryHint = String(params.summary || "").replace(/\s+/g, " ").trim();
|
||||
|
||||
if (params.language === "en") {
|
||||
const templates = [
|
||||
{
|
||||
title: "Real Need Solved",
|
||||
reviewText: `I am part of ${audience}. In ${scenario}, my biggest issue was ${coreNeed}. After using ${productName}, the result is ${expectedResult}. It feels ${firstKeyword} and I am willing to repurchase.`,
|
||||
imagePrompt: `UGC buyer photo set for ${productName}, scenario: ${scenario}, highlight ${coreNeed} solved, realistic lifestyle composition, clear product details, natural lighting, authentic user-generated look`,
|
||||
},
|
||||
{
|
||||
title: "From Concern to Trust",
|
||||
reviewText: `Before purchase I worried about ${riskConcern}. After using it for a while, ${productName} stays ${secondKeyword} and reliable. The overall user experience is much better than expected.`,
|
||||
imagePrompt: `UGC buyer experience photo set for ${productName}, before-after feeling in ${scenario}, close-up and handheld usage moments, realistic texture, social proof style, commercial clarity`,
|
||||
},
|
||||
{
|
||||
title: "Conversion Testimonial",
|
||||
reviewText: `For users like ${audience}, ${productName} is a practical choice. It balances ${firstKeyword}, ${secondKeyword}, and ${thirdKeyword}. The final outcome is ${expectedResult}.`,
|
||||
imagePrompt: `UGC testimonial photo set for ${productName}, mixed angles including scene shot, detail shot and usage shot, trust-driven ecommerce style, clean but real, high conversion mood`,
|
||||
},
|
||||
];
|
||||
const fallback = templates[templates.length - 1]!;
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const template = templates[index] || fallback;
|
||||
const summaryText = summaryHint ? ` ${summaryHint}` : "";
|
||||
return {
|
||||
index: index + 1,
|
||||
title: `${template.title} ${index + 1}`,
|
||||
reviewText: `${template.reviewText}${summaryText}`.trim(),
|
||||
imagePrompt: template.imagePrompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const templates = [
|
||||
{
|
||||
title: "真实需求反馈",
|
||||
reviewText: `我属于${audience},在${scenario}里最头疼的是${coreNeed}。用了${productName}后,明显感受到${expectedResult},整体体验${firstKeyword},确实值得回购。`,
|
||||
imagePrompt: `${productName}买家真实晒单,场景:${scenario},突出“${coreNeed}被解决”的使用结果,生活化拍摄,真实自然光,产品细节清晰,电商高转化氛围`,
|
||||
},
|
||||
{
|
||||
title: "顾虑消除反馈",
|
||||
reviewText: `下单前我最担心${riskConcern},实际使用后发现${productName}${secondKeyword},稳定性也不错。连续使用一段时间后效果依旧在线。`,
|
||||
imagePrompt: `${productName}买家体验晒单,表达“从担心到放心”的转变,含近景细节、手持使用、场景实拍,真实用户视角,质感干净`,
|
||||
},
|
||||
{
|
||||
title: "转化口碑反馈",
|
||||
reviewText: `如果你也是${audience},并且在${scenario}下有${coreNeed},这款${productName}真的很合适。它兼顾了${firstKeyword}、${secondKeyword}和${thirdKeyword},结果就是${expectedResult}。`,
|
||||
imagePrompt: `${productName}买家口碑晒单,三张图分别体现场景图、细节图、结果图,构图统一,真实可信,强化转化购买意愿`,
|
||||
},
|
||||
];
|
||||
const fallback = templates[templates.length - 1]!;
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const template = templates[index] || fallback;
|
||||
const summaryText = summaryHint ? ` ${summaryHint}` : "";
|
||||
return {
|
||||
index: index + 1,
|
||||
title: `${template.title}${index + 1}`,
|
||||
reviewText: `${template.reviewText}${summaryText}`.trim(),
|
||||
imagePrompt: template.imagePrompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDetailResultMarkdown(params: {
|
||||
summary?: string;
|
||||
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
|
||||
@ -498,6 +606,7 @@ function buildDetailResultMarkdown(params: {
|
||||
e?: string;
|
||||
};
|
||||
}>;
|
||||
reviewGroups?: DetailReviewGroup[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("### AI详情生成完成");
|
||||
@ -581,6 +690,41 @@ function buildDetailResultMarkdown(params: {
|
||||
);
|
||||
lines.push("");
|
||||
});
|
||||
if (params.reviewGroups?.length) {
|
||||
lines.push("### 买家评价与晒图");
|
||||
params.reviewGroups.forEach((group, index) => {
|
||||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||||
group.images.forEach((image) => {
|
||||
lines.push(
|
||||
``
|
||||
);
|
||||
});
|
||||
lines.push("");
|
||||
});
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function buildDetailReviewAppendMarkdown(params: {
|
||||
groups: DetailReviewGroup[];
|
||||
failedCount?: number;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("### 买家评价与晒图已追加");
|
||||
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
|
||||
if (params.failedCount && params.failedCount > 0) {
|
||||
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
|
||||
}
|
||||
lines.push("");
|
||||
params.groups.forEach((group, index) => {
|
||||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||||
group.images.forEach((image) => {
|
||||
lines.push(
|
||||
``
|
||||
);
|
||||
});
|
||||
lines.push("");
|
||||
});
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
@ -614,6 +758,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
|
||||
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
|
||||
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
|
||||
productSpu: "",
|
||||
demandType: "",
|
||||
productName: "",
|
||||
targetAudience: "",
|
||||
mainUser: "",
|
||||
@ -1483,6 +1629,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const normalizedDetailBrief = useMemo(() => {
|
||||
const normalizeValue = (value: string) => value.replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
productSpu: normalizeValue(detailBrief.productSpu),
|
||||
demandType: normalizeValue(detailBrief.demandType),
|
||||
productName: normalizeValue(detailBrief.productName),
|
||||
targetAudience: normalizeValue(detailBrief.targetAudience),
|
||||
mainUser: normalizeValue(detailBrief.mainUser),
|
||||
@ -1514,77 +1662,97 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
return issues;
|
||||
}, [detailSlots, normalizedDetailBrief]);
|
||||
|
||||
const hasGeneratedDetailImages = useMemo(
|
||||
() =>
|
||||
nodes.some(
|
||||
(node) =>
|
||||
node.type === "image" &&
|
||||
Boolean((node.data as any)?.metadata?.detailSectionIndex)
|
||||
),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const toggleDetailSection = useCallback((key: DetailSectionKey) => {
|
||||
setDetailSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const raw = localStorage.getItem(getDetailArchiveStorageKey(canvasId));
|
||||
if (!raw) {
|
||||
setDetailArchives([]);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as DetailArchive[];
|
||||
const sanitized = Array.isArray(parsed)
|
||||
? parsed
|
||||
.filter((item) => item?.id && item?.createdAt)
|
||||
.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: String(item.title || "未命名详情项目"),
|
||||
prompt: String(item.prompt || ""),
|
||||
brief: {
|
||||
productName: String(item.brief?.productName || ""),
|
||||
targetAudience: String(item.brief?.targetAudience || ""),
|
||||
mainUser: String(item.brief?.mainUser || ""),
|
||||
usageScenario: String(item.brief?.usageScenario || ""),
|
||||
triggerMoment: String(item.brief?.triggerMoment || ""),
|
||||
coreNeed: String(item.brief?.coreNeed || ""),
|
||||
riskConcern: String(item.brief?.riskConcern || ""),
|
||||
expectedResult: String(item.brief?.expectedResult || ""),
|
||||
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
|
||||
positioningStatement: String(item.brief?.positioningStatement || ""),
|
||||
userVoice: String(item.brief?.userVoice || ""),
|
||||
competitorReference: String(item.brief?.competitorReference || ""),
|
||||
priceBand: String(item.brief?.priceBand || ""),
|
||||
brandTone: String(item.brief?.brandTone || ""),
|
||||
bannedWords: String(item.brief?.bannedWords || ""),
|
||||
},
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = item.slots?.[index];
|
||||
if (!slot?.url) return null;
|
||||
return {
|
||||
url: String(slot.url),
|
||||
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
|
||||
contentType: String(slot.contentType || "image/jpeg"),
|
||||
} as PickedObjectAttachment;
|
||||
}),
|
||||
createdAt: Number(item.createdAt) || Date.now(),
|
||||
summary: item.summary ? String(item.summary) : undefined,
|
||||
imageCount: Number(item.imageCount || 0) || undefined,
|
||||
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
: [];
|
||||
setDetailArchives(sanitized.slice(0, 30));
|
||||
} catch (error) {
|
||||
console.error("[CanvasChat] Failed to load detail archives:", error);
|
||||
setDetailArchives([]);
|
||||
}
|
||||
}, [canvasId]);
|
||||
let isCancelled = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
getDetailArchiveStorageKey(canvasId),
|
||||
JSON.stringify(detailArchives.slice(0, 30))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[CanvasChat] Failed to save detail archives:", error);
|
||||
}
|
||||
}, [canvasId, detailArchives]);
|
||||
const loadDetailArchives = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (isCancelled) return;
|
||||
if (!response.ok || !result?.success) {
|
||||
setDetailArchives([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : [];
|
||||
const sanitized = parsed
|
||||
.filter((item) => item?.id && item?.createdAt)
|
||||
.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: String(item.title || "未命名详情项目"),
|
||||
prompt: String(item.prompt || ""),
|
||||
brief: {
|
||||
productSpu: String(item.brief?.productSpu || ""),
|
||||
demandType: String(item.brief?.demandType || ""),
|
||||
productName: String(item.brief?.productName || ""),
|
||||
targetAudience: String(item.brief?.targetAudience || ""),
|
||||
mainUser: String(item.brief?.mainUser || ""),
|
||||
usageScenario: String(item.brief?.usageScenario || ""),
|
||||
triggerMoment: String(item.brief?.triggerMoment || ""),
|
||||
coreNeed: String(item.brief?.coreNeed || ""),
|
||||
riskConcern: String(item.brief?.riskConcern || ""),
|
||||
expectedResult: String(item.brief?.expectedResult || ""),
|
||||
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
|
||||
positioningStatement: String(item.brief?.positioningStatement || ""),
|
||||
userVoice: String(item.brief?.userVoice || ""),
|
||||
competitorReference: String(item.brief?.competitorReference || ""),
|
||||
priceBand: String(item.brief?.priceBand || ""),
|
||||
brandTone: String(item.brief?.brandTone || ""),
|
||||
bannedWords: String(item.brief?.bannedWords || ""),
|
||||
},
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = item.slots?.[index];
|
||||
if (!slot?.url) return null;
|
||||
return {
|
||||
url: String(slot.url),
|
||||
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
|
||||
contentType: String(slot.contentType || "image/jpeg"),
|
||||
} as PickedObjectAttachment;
|
||||
}),
|
||||
createdAt:
|
||||
typeof item.createdAt === "number"
|
||||
? item.createdAt
|
||||
: (() => {
|
||||
const timestamp = Date.parse(String(item.createdAt || ""));
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
})(),
|
||||
summary: item.summary ? String(item.summary) : undefined,
|
||||
imageCount: Number(item.imageCount || 0) || undefined,
|
||||
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
setDetailArchives(sanitized.slice(0, 30));
|
||||
} catch (error) {
|
||||
if (isCancelled) return;
|
||||
console.error("[CanvasChat] Failed to load detail archives:", error);
|
||||
setDetailArchives([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadDetailArchives();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [canvasId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoFrameSlots.length === 0) return;
|
||||
@ -2378,6 +2546,27 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
[updateDetailSlotAttachment]
|
||||
);
|
||||
|
||||
const saveDetailArchiveToDatabase = useCallback(
|
||||
async (archive: DetailArchive) => {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ archive }),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result?.success) {
|
||||
console.error("[CanvasChat] Failed to persist detail archive:", result?.error || response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CanvasChat] Failed to persist detail archive:", error);
|
||||
}
|
||||
},
|
||||
[canvasId]
|
||||
);
|
||||
|
||||
const saveDetailArchiveSnapshot = useCallback(
|
||||
(params: {
|
||||
summary?: string;
|
||||
@ -2389,16 +2578,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
}) => {
|
||||
const archiveId = generateUUID();
|
||||
const promptText = params.prompt.replace(/\s+/g, " ").trim();
|
||||
const titleSeed =
|
||||
params.brief.productName ||
|
||||
params.brief.coreNeed ||
|
||||
params.brief.usageScenario ||
|
||||
"AI详情项目";
|
||||
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
|
||||
const demandSeed = String(
|
||||
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const createdAt = Date.now();
|
||||
const archiveTitle =
|
||||
spuSeed && demandSeed
|
||||
? `${spuSeed}+${demandSeed}`
|
||||
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
|
||||
|
||||
const archive: DetailArchive = {
|
||||
id: archiveId,
|
||||
title: `${titleSeed}${new Date(createdAt).toLocaleString()}`,
|
||||
title: archiveTitle,
|
||||
prompt: promptText,
|
||||
brief: { ...params.brief },
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
@ -2418,8 +2612,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
};
|
||||
|
||||
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
|
||||
void saveDetailArchiveToDatabase(archive);
|
||||
},
|
||||
[]
|
||||
[saveDetailArchiveToDatabase]
|
||||
);
|
||||
|
||||
const loadDetailArchive = useCallback((archive: DetailArchive) => {
|
||||
@ -2487,9 +2682,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const basePrompt = brief.productName
|
||||
? `${brief.productName}详情页生成`
|
||||
: "电商详情页生成";
|
||||
const demandHint = brief.demandType ? `,需求类型:${brief.demandType}` : "";
|
||||
const detailPrompt =
|
||||
userText ||
|
||||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}`;
|
||||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`;
|
||||
const archiveSnapshot = {
|
||||
prompt: detailPrompt,
|
||||
brief: { ...detailBrief },
|
||||
@ -2529,6 +2725,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
: "未上传搭配产品图:不强制搭配展示屏。";
|
||||
const detailBriefText = [
|
||||
"【AI详情生成输入】",
|
||||
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
|
||||
brief.demandType ? `需求类型:${brief.demandType}` : "",
|
||||
brief.productName ? `产品:${brief.productName}` : "",
|
||||
`主购买人群:${brief.targetAudience}`,
|
||||
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
|
||||
@ -2551,13 +2749,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh";
|
||||
|
||||
setIsDetailGenerating(true);
|
||||
setDetailProgress({
|
||||
status: "running",
|
||||
stage: "Agent 正在解析需求并规划详情结构...",
|
||||
current: 0,
|
||||
total: Math.max(1, detailCount + 1),
|
||||
total: Math.max(1, detailCount + 1 + DETAIL_REVIEW_COUNT),
|
||||
});
|
||||
try {
|
||||
const detailReferenceAttachments = DETAIL_SLOT_CONFIGS.map((config, index) => {
|
||||
@ -2615,7 +2814,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
referenceImageNotes,
|
||||
modelImageUrl: modelAttachment.url,
|
||||
bundleImageUrls: bundleAttachments.map((item) => item.url),
|
||||
outputLanguage: /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh",
|
||||
outputLanguage,
|
||||
brief,
|
||||
}),
|
||||
});
|
||||
@ -2633,7 +2832,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
throw new Error("未获取到有效分镜提示词");
|
||||
}
|
||||
const screenSpecs = Array.isArray(planData.screens) ? planData.screens : [];
|
||||
const totalSteps = prompts.length + 1;
|
||||
const reviewPlans = buildDetailReviewPlans({
|
||||
brief,
|
||||
summary: planData.summary,
|
||||
language: outputLanguage,
|
||||
count: DETAIL_REVIEW_COUNT,
|
||||
});
|
||||
const totalSteps = prompts.length + 1 + reviewPlans.length;
|
||||
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
|
||||
? planData.qualityChecks?.issues.filter(Boolean)
|
||||
: [];
|
||||
@ -2801,6 +3006,129 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
});
|
||||
}
|
||||
|
||||
const reviewGroups: DetailReviewGroup[] = [];
|
||||
const reviewFailures: string[] = [];
|
||||
const detailRows = Math.ceil(prompts.length / 3);
|
||||
const detailStartX = 120;
|
||||
const detailStartY = 120;
|
||||
const detailSpacing = 24;
|
||||
const reviewStartY =
|
||||
detailStartY + detailRows * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
|
||||
|
||||
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
|
||||
const reviewPlan = reviewPlans[reviewIndex]!;
|
||||
const reviewStepCurrent = prompts.length + 2 + reviewIndex;
|
||||
setDetailProgress({
|
||||
status: "running",
|
||||
stage: `正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}`,
|
||||
current: reviewStepCurrent,
|
||||
total: totalSteps,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### AI详情生成\n详情图已完成,正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}...`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
||||
const reviewPrompt = [
|
||||
reviewPlan.imagePrompt,
|
||||
`评价文本:${reviewPlan.reviewText}`,
|
||||
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
|
||||
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图要保持同一叙事主题但角度不同。`,
|
||||
"图片内容要看得见产品主体和真实使用痕迹,适合放在详情页评价区。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const reviewReferenceImages = [
|
||||
...productAttachments.map((item) => item.url),
|
||||
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
|
||||
modelAttachment.url,
|
||||
].filter(Boolean);
|
||||
|
||||
const reviewResponse = await fetch("/api/canvas/generate/image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: reviewPrompt,
|
||||
model: modelForGeneration,
|
||||
aspectRatio: "1:1",
|
||||
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||||
negativePrompt: imageParams.negativePrompt,
|
||||
referenceImage: productAttachments[0]?.url,
|
||||
referenceImages: reviewReferenceImages,
|
||||
}),
|
||||
});
|
||||
const reviewResult = await reviewResponse.json();
|
||||
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
|
||||
? reviewResult.data.images
|
||||
: [];
|
||||
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
|
||||
reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewImages = reviewImagesRaw
|
||||
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
|
||||
.map((item: any) => ({
|
||||
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
|
||||
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
|
||||
}))
|
||||
.filter((item: { originalUrl: string; thumbnailUrl: string }) => Boolean(item.originalUrl));
|
||||
|
||||
if (reviewImages.length === 0) {
|
||||
reviewFailures.push(`评价${reviewIndex + 1}未返回有效图片`);
|
||||
continue;
|
||||
}
|
||||
|
||||
reviewGroups.push({
|
||||
reviewText: reviewPlan.reviewText,
|
||||
images: reviewImages,
|
||||
});
|
||||
|
||||
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
|
||||
const x =
|
||||
detailStartX +
|
||||
imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
|
||||
const y =
|
||||
reviewStartY +
|
||||
reviewIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
|
||||
addNode({
|
||||
type: "image",
|
||||
position: { x, y },
|
||||
size: {
|
||||
width: DETAIL_REVIEW_IMAGE_WIDTH,
|
||||
height: DETAIL_REVIEW_IMAGE_HEIGHT,
|
||||
},
|
||||
data: {
|
||||
src: image.originalUrl || image.thumbnailUrl,
|
||||
status: "completed",
|
||||
prompt: reviewPlan.imagePrompt,
|
||||
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
|
||||
note: reviewPlan.reviewText,
|
||||
metadata: {
|
||||
detailReviewIndex: reviewPlan.index,
|
||||
detailReviewTitle: reviewPlan.title,
|
||||
detailReviewText: reviewPlan.reviewText,
|
||||
detailReviewImageIndex: imageIndex + 1,
|
||||
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const finalMarkdown = buildDetailResultMarkdown({
|
||||
summary: planData.summary,
|
||||
images: generatedImages,
|
||||
@ -2809,6 +3137,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
qualityChecks: planData.qualityChecks,
|
||||
globalStyle: planData.globalStyle,
|
||||
screens: screenSpecs,
|
||||
reviewGroups,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
@ -2827,7 +3156,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
);
|
||||
setDetailProgress({
|
||||
status: "done",
|
||||
stage: `已完成 ${generatedImages.length} 张详情图`,
|
||||
stage:
|
||||
reviewFailures.length > 0
|
||||
? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条`
|
||||
: `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`,
|
||||
current: totalSteps,
|
||||
total: totalSteps,
|
||||
});
|
||||
@ -2840,7 +3172,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
slots: archiveSnapshot.slots,
|
||||
});
|
||||
|
||||
toast.success(`AI详情生成完成,共 ${generatedImages.length} 张`);
|
||||
if (reviewFailures.length > 0) {
|
||||
toast.warning(
|
||||
`详情图完成 ${generatedImages.length} 张,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`AI详情生成完成:详情图 ${generatedImages.length} 张,评价晒图 ${reviewGroups.length} 条`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
|
||||
setDetailProgress((prev) => ({
|
||||
@ -2884,6 +3224,283 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
siteSettings?.canvasDefaultModelId,
|
||||
]);
|
||||
|
||||
const handleGenerateMoreDetailReviews = useCallback(async () => {
|
||||
if (isDetailGenerating) return;
|
||||
if (!hasGeneratedDetailImages) {
|
||||
toast.error("请先完成一轮AI详情生成,再追加评价晒图");
|
||||
return;
|
||||
}
|
||||
|
||||
const brief = normalizedDetailBrief;
|
||||
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
|
||||
toast.error("请先补充 AI详情生成必填项后再追加评价晒图");
|
||||
return;
|
||||
}
|
||||
|
||||
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
|
||||
.map((index) => detailSlots[index])
|
||||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||||
if (productAttachments.length < 5) {
|
||||
toast.error("请先补齐5张产品图,再追加评价晒图");
|
||||
return;
|
||||
}
|
||||
const modelAttachment =
|
||||
DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null;
|
||||
if (!modelAttachment?.url) {
|
||||
toast.error("请先补齐模特图,再追加评价晒图");
|
||||
return;
|
||||
}
|
||||
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
|
||||
.map((index) => detailSlots[index])
|
||||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||||
|
||||
const languageSeed = [
|
||||
brief.productName,
|
||||
brief.targetAudience,
|
||||
brief.usageScenario,
|
||||
brief.coreNeed,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(languageSeed) ? "en" : "zh";
|
||||
const reviewPlans = buildDetailReviewPlans({
|
||||
brief,
|
||||
language: outputLanguage,
|
||||
count: DETAIL_REVIEW_REGEN_COUNT,
|
||||
});
|
||||
if (reviewPlans.length === 0) {
|
||||
toast.error("未生成可用的评价方案");
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantMessageId = generateUUID();
|
||||
const modelForGeneration =
|
||||
selectedModelId !== "auto"
|
||||
? selectedModelId
|
||||
: siteSettings?.canvasAutoModeDefaultModelId ||
|
||||
siteSettings?.canvasDefaultModelId ||
|
||||
undefined;
|
||||
const existingImageBottom = nodes
|
||||
.filter((node) => node.type === "image")
|
||||
.reduce((max, node) => {
|
||||
const currentBottom = Number(node.position?.y || 0) + Number(node.size?.height || 0);
|
||||
return Math.max(max, currentBottom);
|
||||
}, 120);
|
||||
const reviewStartY = existingImageBottom + 120;
|
||||
const reviewStartX = 120;
|
||||
const reviewGroups: DetailReviewGroup[] = [];
|
||||
const reviewFailures: string[] = [];
|
||||
const batchId = generateUUID();
|
||||
|
||||
setIsDetailGenerating(true);
|
||||
setDetailProgress({
|
||||
status: "running",
|
||||
stage: "正在追加买家评价与晒图...",
|
||||
current: 0,
|
||||
total: reviewPlans.length,
|
||||
});
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: assistantMessageId,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### 买家评价与晒图\n正在生成新的 ${reviewPlans.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张...`,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
]);
|
||||
|
||||
try {
|
||||
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
|
||||
const reviewPlan = reviewPlans[reviewIndex]!;
|
||||
setDetailProgress({
|
||||
status: "running",
|
||||
stage: `正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条评价晒图`,
|
||||
current: reviewIndex + 1,
|
||||
total: reviewPlans.length,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### 买家评价与晒图\n正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条...`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: message
|
||||
)
|
||||
);
|
||||
|
||||
const reviewPrompt = [
|
||||
reviewPlan.imagePrompt,
|
||||
`评价文本:${reviewPlan.reviewText}`,
|
||||
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
|
||||
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图保持同一叙事主题但角度不同。`,
|
||||
"图片内容需要清晰展示产品主体与使用场景,可直接用于详情页评价区。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const reviewReferenceImages = [
|
||||
...productAttachments.map((item) => item.url),
|
||||
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
|
||||
modelAttachment.url,
|
||||
].filter(Boolean);
|
||||
|
||||
const reviewResponse = await fetch("/api/canvas/generate/image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: reviewPrompt,
|
||||
model: modelForGeneration,
|
||||
aspectRatio: "1:1",
|
||||
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||||
negativePrompt: imageParams.negativePrompt,
|
||||
referenceImage: productAttachments[0]?.url,
|
||||
referenceImages: reviewReferenceImages,
|
||||
}),
|
||||
});
|
||||
const reviewResult = await reviewResponse.json();
|
||||
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
|
||||
? reviewResult.data.images
|
||||
: [];
|
||||
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
|
||||
reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewImages = reviewImagesRaw
|
||||
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
|
||||
.map((item: any) => ({
|
||||
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
|
||||
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
|
||||
}))
|
||||
.filter((item: { originalUrl: string; thumbnailUrl: string }) =>
|
||||
Boolean(item.originalUrl)
|
||||
);
|
||||
if (reviewImages.length === 0) {
|
||||
reviewFailures.push(`第${reviewIndex + 1}条无有效图片`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowIndex = reviewGroups.length;
|
||||
reviewGroups.push({
|
||||
reviewText: reviewPlan.reviewText,
|
||||
images: reviewImages,
|
||||
});
|
||||
|
||||
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
|
||||
const x = reviewStartX + imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
|
||||
const y = reviewStartY + rowIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
|
||||
addNode({
|
||||
type: "image",
|
||||
position: { x, y },
|
||||
size: {
|
||||
width: DETAIL_REVIEW_IMAGE_WIDTH,
|
||||
height: DETAIL_REVIEW_IMAGE_HEIGHT,
|
||||
},
|
||||
data: {
|
||||
src: image.originalUrl || image.thumbnailUrl,
|
||||
status: "completed",
|
||||
prompt: reviewPlan.imagePrompt,
|
||||
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
|
||||
note: reviewPlan.reviewText,
|
||||
metadata: {
|
||||
detailReviewIndex: reviewPlan.index,
|
||||
detailReviewTitle: reviewPlan.title,
|
||||
detailReviewText: reviewPlan.reviewText,
|
||||
detailReviewImageIndex: imageIndex + 1,
|
||||
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||||
detailReviewBatchId: batchId,
|
||||
detailReviewBatchType: "manual-append",
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const finalText = buildDetailReviewAppendMarkdown({
|
||||
groups: reviewGroups,
|
||||
failedCount: reviewFailures.length,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: finalText,
|
||||
},
|
||||
],
|
||||
}
|
||||
: message
|
||||
)
|
||||
);
|
||||
setDetailProgress({
|
||||
status: "done",
|
||||
stage:
|
||||
reviewFailures.length > 0
|
||||
? `已追加 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
|
||||
: `已追加 ${reviewGroups.length} 条买家评价晒图`,
|
||||
current: reviewPlans.length,
|
||||
total: reviewPlans.length,
|
||||
});
|
||||
|
||||
if (reviewFailures.length > 0) {
|
||||
toast.warning(`追加成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`);
|
||||
} else {
|
||||
toast.success(`已追加 ${reviewGroups.length} 条买家评价晒图`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "追加买家评价与晒图失败";
|
||||
setDetailProgress({
|
||||
status: "error",
|
||||
stage: errMsg,
|
||||
current: 0,
|
||||
total: reviewPlans.length,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### 买家评价与晒图追加失败\n${errMsg}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: message
|
||||
)
|
||||
);
|
||||
toast.error(errMsg);
|
||||
} finally {
|
||||
setIsDetailGenerating(false);
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
detailSlots,
|
||||
hasGeneratedDetailImages,
|
||||
imageParams.negativePrompt,
|
||||
isDetailGenerating,
|
||||
nodes,
|
||||
normalizedDetailBrief,
|
||||
selectedModelId,
|
||||
setMessages,
|
||||
siteSettings?.canvasAutoModeDefaultModelId,
|
||||
siteSettings?.canvasDefaultModelId,
|
||||
]);
|
||||
|
||||
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
|
||||
const handleSend = useCallback(async () => {
|
||||
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
|
||||
@ -3962,6 +4579,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</button>
|
||||
{detailSections.required ? (
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-black/10 p-3 xl:grid-cols-4">
|
||||
<input
|
||||
value={detailBrief.productSpu}
|
||||
onChange={(event) => updateDetailBriefField("productSpu", event.target.value)}
|
||||
title={DETAIL_FIELD_EXAMPLES.productSpu}
|
||||
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
|
||||
placeholder="商品SPU(建议填写)"
|
||||
/>
|
||||
<input
|
||||
value={detailBrief.demandType}
|
||||
onChange={(event) => updateDetailBriefField("demandType", event.target.value)}
|
||||
title={DETAIL_FIELD_EXAMPLES.demandType}
|
||||
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
|
||||
placeholder="需求类型(建议填写)"
|
||||
/>
|
||||
<input
|
||||
value={detailBrief.targetAudience}
|
||||
onChange={(event) => updateDetailBriefField("targetAudience", event.target.value)}
|
||||
@ -4303,6 +4934,34 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasGeneratedDetailImages ? (
|
||||
<div className="mt-2 flex items-center justify-between gap-2 rounded-xl border border-violet-200 bg-violet-50/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium text-violet-700">
|
||||
评价晒图可继续追加
|
||||
</p>
|
||||
<p className="text-[10px] text-violet-600/80">
|
||||
点击后仅新增2条评价,每条3张晒图,不会重跑详情主图。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium",
|
||||
isSubmitting
|
||||
? "cursor-not-allowed border-violet-200 bg-violet-100 text-violet-500/70"
|
||||
: "border-violet-300 bg-white text-violet-700 hover:bg-violet-100"
|
||||
)}
|
||||
onClick={() => {
|
||||
void handleGenerateMoreDetailReviews();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
再生成2条
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
|
||||
|
||||
@ -12,7 +12,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { Download, Minus, Plus } from "lucide-react";
|
||||
|
||||
// 性能优化:判断节点是否在视口内
|
||||
function isNodeInViewport(
|
||||
@ -36,6 +36,48 @@ function isNodeInViewport(
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDownloadFileName(value: string, fallback: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
async function triggerImageDownload(url: string, filename: string): Promise<void> {
|
||||
if (!url) throw new Error("missing_url");
|
||||
const clickLink = (href: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
link.rel = "noopener noreferrer";
|
||||
link.target = "_blank";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
mode: "cors",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`http_${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
clickLink(objectUrl);
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
return;
|
||||
} catch (_error) {
|
||||
clickLink(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function CanvasWorkspace() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
@ -140,6 +182,17 @@ export function CanvasWorkspace() {
|
||||
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
|
||||
);
|
||||
}, [nodes, viewport, containerSize, selectedNodeIds]);
|
||||
|
||||
const selectedImageNodes = useMemo(
|
||||
() =>
|
||||
nodes.filter(
|
||||
(node) =>
|
||||
selectedNodeIds.includes(node.id) &&
|
||||
node.type === "image" &&
|
||||
Boolean((node.data as any)?.src)
|
||||
),
|
||||
[nodes, selectedNodeIds]
|
||||
);
|
||||
|
||||
// Store actions
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
@ -499,6 +552,45 @@ export function CanvasWorkspace() {
|
||||
}
|
||||
}, [isFileDrag, processDroppedFiles]);
|
||||
|
||||
const handleDownloadSelectedImages = useCallback(async () => {
|
||||
if (selectedImageNodes.length === 0) {
|
||||
toast.error("请先框选要下载的图片");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (let index = 0; index < selectedImageNodes.length; index += 1) {
|
||||
const node = selectedImageNodes[index]!;
|
||||
const nodeData = node.data as any;
|
||||
const imageUrl = String(nodeData?.src || "").trim();
|
||||
if (!imageUrl) {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleSeed =
|
||||
String(nodeData?.title || "").trim() ||
|
||||
String(nodeData?.prompt || "").trim() ||
|
||||
`canvas-image-${index + 1}`;
|
||||
const filename = `${sanitizeDownloadFileName(titleSeed, `canvas-image-${index + 1}`)}.png`;
|
||||
|
||||
try {
|
||||
await triggerImageDownload(imageUrl, filename);
|
||||
successCount += 1;
|
||||
} catch (_error) {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.warning(`已下载 ${successCount} 张,失败 ${failedCount} 张`);
|
||||
return;
|
||||
}
|
||||
toast.success(`已开始下载 ${successCount} 张图片`);
|
||||
}, [selectedImageNodes]);
|
||||
|
||||
// 渲染节点
|
||||
const renderNode = (node: CanvasNode) => {
|
||||
const isSelected = selectedNodeIds.includes(node.id);
|
||||
@ -706,6 +798,21 @@ export function CanvasWorkspace() {
|
||||
)}
|
||||
|
||||
{/* 缩放控制器 */}
|
||||
{selectedImageNodes.length > 0 && (
|
||||
<div className="absolute bottom-16 right-4 z-20">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60"
|
||||
onClick={handleDownloadSelectedImages}
|
||||
title="下载当前框选图片"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
下载选中({selectedImageNodes.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
20
lib/db/migrations/0038_add_canvas_detail_archives.sql
Normal file
20
lib/db/migrations/0038_add_canvas_detail_archives.sql
Normal file
@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS "CanvasDetailArchive" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"projectId" uuid NOT NULL REFERENCES "CanvasProject"("id") ON DELETE cascade,
|
||||
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
|
||||
"title" varchar(256) NOT NULL DEFAULT 'AI详情项目',
|
||||
"prompt" text NOT NULL DEFAULT '',
|
||||
"brief" jsonb NOT NULL,
|
||||
"slots" jsonb NOT NULL,
|
||||
"summary" text,
|
||||
"imageCount" integer,
|
||||
"coverUrl" text,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "canvas_detail_archive_project_created_idx"
|
||||
ON "CanvasDetailArchive" ("projectId", "createdAt" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "canvas_detail_archive_user_created_idx"
|
||||
ON "CanvasDetailArchive" ("userId", "createdAt" DESC);
|
||||
@ -169,6 +169,13 @@
|
||||
"when": 1771317600000,
|
||||
"tag": "0037_add_ai_model_avatar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1771502400000,
|
||||
"tag": "0038_add_canvas_detail_archives",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -476,6 +476,28 @@ export const canvasProject = pgTable("CanvasProject", {
|
||||
|
||||
export type CanvasProject = InferSelectModel<typeof canvasProject>;
|
||||
|
||||
// AI详情项目存档表
|
||||
export const canvasDetailArchive = pgTable("CanvasDetailArchive", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
projectId: uuid("projectId")
|
||||
.notNull()
|
||||
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 }).notNull().default("AI详情项目"),
|
||||
prompt: text("prompt").notNull().default(""),
|
||||
brief: jsonb("brief").notNull(),
|
||||
slots: jsonb("slots").notNull(),
|
||||
summary: text("summary"),
|
||||
imageCount: integer("imageCount"),
|
||||
coverUrl: text("coverUrl"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type CanvasDetailArchive = InferSelectModel<typeof canvasDetailArchive>;
|
||||
|
||||
// 画布节点表(可选,用于更细粒度的查询)
|
||||
export const canvasNode = pgTable("CanvasNode", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user