diff --git a/app/api/canvas/[id]/detail-archives/route.ts b/app/api/canvas/[id]/detail-archives/route.ts new file mode 100644 index 000000000..c72363082 --- /dev/null +++ b/app/api/canvas/[id]/detail-archives/route.ts @@ -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; + 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; + 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; + 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 }); + } +} diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index e99776ddb..4eb7035c6 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -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; 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}` : "", diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index ba923a4ca..883c34cb2 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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 = { + 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( + `![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})` + ); + }); + 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( + `![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})` + ); + }); + lines.push(""); + }); return lines.join("\n").trim(); } @@ -614,6 +758,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState(null); const [detailArchives, setDetailArchives] = useState([]); const [detailBrief, setDetailBrief] = useState({ + 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 {detailSections.required ? (
+ 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(建议填写)" + /> + 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="需求类型(建议填写)" + /> updateDetailBriefField("targetAudience", event.target.value)} @@ -4303,6 +4934,34 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
)} + + {hasGeneratedDetailImages ? ( +
+
+

+ 评价晒图可继续追加 +

+

+ 点击后仅新增2条评价,每条3张晒图,不会重跑详情主图。 +

+
+ +
+ ) : null} )} {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */} diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 10e92ad23..7665fe7b7 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -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 { + 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(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 && ( +
+ +
+ )} +