diff --git a/app/api/canvas/[id]/detail-archives/route.ts b/app/api/canvas/[id]/detail-archives/route.ts index 04878ab0c..559f099bf 100644 --- a/app/api/canvas/[id]/detail-archives/route.ts +++ b/app/api/canvas/[id]/detail-archives/route.ts @@ -30,6 +30,12 @@ type DetailArchiveSlot = { contentType?: string; } | null; +type DetailGenerationMode = "classic" | "agent_layout"; + +function sanitizeGenerationMode(value: unknown): DetailGenerationMode { + return value === "agent_layout" ? "agent_layout" : "classic"; +} + function sanitizeBrief(input: unknown): DetailArchiveBrief { const raw = (input || {}) as Record; const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim(); @@ -136,6 +142,7 @@ export async function POST( const summary = String(archiveRaw.summary || "").trim(); const imageCount = Number(archiveRaw.imageCount); const coverUrl = String(archiveRaw.coverUrl || "").trim(); + const generationMode = sanitizeGenerationMode(archiveRaw.generationMode); const brief = sanitizeBrief(archiveRaw.brief); const slots = sanitizeSlots(archiveRaw.slots); @@ -147,6 +154,7 @@ export async function POST( userId: session.user.id, title, prompt, + generationMode, brief, slots, summary: summary || null, @@ -190,6 +198,7 @@ export async function PATCH( const summary = String(archiveRaw.summary || "").trim(); const imageCount = Number(archiveRaw.imageCount); const coverUrl = String(archiveRaw.coverUrl || "").trim(); + const generationMode = sanitizeGenerationMode(archiveRaw.generationMode); const brief = sanitizeBrief(archiveRaw.brief); const slots = sanitizeSlots(archiveRaw.slots); @@ -198,6 +207,7 @@ export async function PATCH( .set({ title, prompt, + generationMode, brief, slots, summary: summary || null, diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 993d98aba..371189ced 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -86,6 +86,7 @@ async function generateTextWithTimeout(params: any, timeoutMs: number) { type DetailPlanRequest = { prompt?: string; count?: number; + generationMode?: string; outputLanguage?: string; referenceImages?: string[]; referenceImageNotes?: string[]; @@ -112,6 +113,8 @@ type DetailPlanRequest = { }; }; +type DetailGenerationMode = "classic" | "agent_layout"; + type DetailBrief = { productSpu: string; demandType: string; @@ -156,6 +159,7 @@ type DetailPlanScreen = { }; type DetailPlanResponse = { + generationMode?: DetailGenerationMode; summary: string; prompts: string[]; screenSize: { @@ -786,6 +790,10 @@ function normalizeOutputLanguage(value: unknown): "zh" | "en" { return "zh"; } +function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode { + return value === "agent_layout" ? "agent_layout" : "classic"; +} + function normalizeDetailBrief(value: unknown): DetailBrief { const raw = (value || {}) as Record; const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim(); @@ -1127,6 +1135,7 @@ function buildFallbackScreens(params: { function buildFallbackPlan(params: { prompt: string; count: number; + generationMode: DetailGenerationMode; language: "zh" | "en"; brief: DetailBrief; coreFocus: DetailCoreFocusContext; @@ -1154,6 +1163,7 @@ function buildFallbackPlan(params: { coreFocus: params.coreFocus, }); return { + generationMode: params.generationMode, summary: language === "en" ? `Generated ${count} storyboard frames with fixed size 790x1300.` @@ -1653,6 +1663,7 @@ export async function POST(request: Request) { const hasModelImage = /^https?:\/\//i.test(modelImageUrl); const hasBundleImages = bundleImageUrls.length > 0; const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount; + const generationMode = normalizeDetailGenerationMode(body.generationMode); const language = normalizeOutputLanguage(body.outputLanguage); const brief = normalizeDetailBrief(body.brief); if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) { @@ -1668,6 +1679,7 @@ export async function POST(request: Request) { const fallback = buildFallbackPlan({ prompt, count, + generationMode, language, brief, coreFocus, @@ -1734,6 +1746,7 @@ export async function POST(request: Request) { buildBriefText(brief, language), `目标分镜数量:${count}`, `输出语言:${language === "en" ? "English" : "中文"}`, + `生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`, "任务类型:电商详情页“AI详情生成”", "必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。", `核心卖点锚点(整套必须围绕):${coreFocus.anchor}`, @@ -1796,6 +1809,9 @@ export async function POST(request: Request) { `2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` + `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + + (generationMode === "agent_layout" + ? `4.1) 当前为AGENT排版模式:visualPrompt 只描述画面层,禁止要求模型生成标题/副标题/正文等可见文字。\n` + : "") + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + `5.1) 首屏主标题必须是“命名词+需求句”结构(6-20字,例如“甩甩杆,自拍不手抖”),并配一条结果副标题,直接回答“为什么值得买”。\n` + `5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` + @@ -1969,6 +1985,7 @@ export async function POST(request: Request) { const response: DetailPlanResponse = { summary, prompts, + generationMode, screenSize: { width: 790, height: 1300, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 92dc0e0c8..7e1dfb0d0 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -749,11 +749,13 @@ type DetailSlotConfig = { }; type DetailSectionKey = "required" | "strategy" | "assets" | "archives"; +type DetailGenerationMode = "classic" | "agent_layout"; type DetailArchive = { id: string; title: string; prompt: string; + generationMode: DetailGenerationMode; brief: DetailBriefForm; slots: Array; createdAt: number; @@ -764,6 +766,7 @@ type DetailArchive = { }; type DetailPlanResponse = { + generationMode?: DetailGenerationMode; summary?: string; prompts?: string[]; screenSize?: { @@ -827,6 +830,7 @@ type DetailPlanResponse = { providerType: string; }; }; +type DetailPlanScreenSpec = NonNullable[number]; const DETAIL_TARGET_WIDTH = 790; const DETAIL_TARGET_HEIGHT = 1300; @@ -880,6 +884,22 @@ const DETAIL_FIELD_EXAMPLES: Record = { brandTone: "示例:专业、可靠、亲和", bannedWords: "示例:最强、第一、100%治愈", }; +const DETAIL_GENERATION_MODE_OPTIONS: Array<{ + value: DetailGenerationMode; + label: string; + description: string; +}> = [ + { + value: "classic", + label: "传统生图", + description: "整页直接生图,速度更快", + }, + { + value: "agent_layout", + label: "AGENT排版", + description: "生图只出画面,文字层可编辑", + }, +]; const SESSION_TITLE_MAX_LEN = 18; @@ -936,6 +956,24 @@ type DetailScreenReviewResult = { } | null; }; +function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode { + return value === "agent_layout" ? "agent_layout" : "classic"; +} + +function extractFirstNumber(value: string, fallback: number): number { + const matched = String(value || "").match(/(\d{1,3})/); + if (!matched) return fallback; + const parsed = Number(matched[1]); + if (!Number.isFinite(parsed)) return fallback; + return parsed; +} + +function extractFontWeight(value: string, fallback = "600"): string { + const matched = String(value || "").match(/(?:\/|weight[::]?\s*)(\d{3})/i); + if (!matched) return fallback; + return matched[1] || fallback; +} + function splitDetailKeywords(value: string, limit = 3): string[] { const raw = String(value || "") .split(/[\n,,、;;|]/) @@ -1028,6 +1066,7 @@ function buildDetailReviewPlans(params: { } function buildDetailResultMarkdown(params: { + generationMode?: DetailGenerationMode; summary?: string; images: Array<{ originalUrl: string; thumbnailUrl: string }>; prompts: string[]; @@ -1077,6 +1116,11 @@ function buildDetailResultMarkdown(params: { }): string { const lines: string[] = []; lines.push("### AI详情生成完成"); + if (params.generationMode) { + lines.push( + `生成路线:${params.generationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页生图)"}` + ); + } if (params.summary) { lines.push(params.summary); } @@ -1200,6 +1244,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [attachments, setAttachments] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false); + const [detailGenerationMode, setDetailGenerationMode] = useState("classic"); const [isDetailGenerating, setIsDetailGenerating] = useState(false); const [detailSections, setDetailSections] = useState>({ required: true, @@ -2247,6 +2292,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if (!selectedModelId && siteSettings) { // 使用系统设置的默认模型,如果没有设置则使用Agent模式 const defaultModel = siteSettings.canvasDefaultModelId || "auto"; + currentModelIdRef.current = defaultModel; setSelectedModelId(defaultModel); } }, [selectedModelId, siteSettings]); @@ -2259,6 +2305,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF useEffect(() => { if (!modelSelectValue || modelSelectValue === selectedModelId) return; + currentModelIdRef.current = modelSelectValue; setSelectedModelId(modelSelectValue); }, [modelSelectValue, selectedModelId]); @@ -2332,6 +2379,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const handleSelectGenerationMode = useCallback( (mode: "agent" | "image" | "video" | "detail") => { if (mode === "agent") { + currentModelIdRef.current = "auto"; setSelectedModelId("auto"); setIsDetailGenerationMode(false); return; @@ -2342,6 +2390,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("AI详情生成功能未开启"); return; } + currentModelIdRef.current = "auto"; setSelectedModelId("auto"); setIsDetailGenerationMode(true); setIsDetailWorkbenchCollapsed(true); @@ -2361,6 +2410,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("当前没有可用的图像模型"); return; } + currentModelIdRef.current = preferredImageModelId; setSelectedModelId(preferredImageModelId); setIsDetailGenerationMode(false); return; @@ -2375,6 +2425,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("当前没有可用的视频模型"); return; } + currentModelIdRef.current = preferredVideoModelId; setSelectedModelId(preferredVideoModelId); setIsDetailGenerationMode(false); }, @@ -2468,6 +2519,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF id: String(item.id), title: String(item.title || "未命名详情项目"), prompt: String(item.prompt || ""), + generationMode: normalizeDetailGenerationMode((item as any).generationMode), brief: { productSpu: String(item.brief?.productSpu || ""), demandType: String(item.brief?.demandType || ""), @@ -2697,6 +2749,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF parts.push({ type: "text" as const, text: textContent }); } + // 强制同步当前模型,避免模型切换后瞬时发送命中旧模型 + currentModelIdRef.current = selectedModelId; + sendMessage({ role: "user", parts, @@ -3383,6 +3438,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageCount?: number; coverUrl?: string; prompt: string; + generationMode: DetailGenerationMode; brief: DetailBriefForm; slots: Array; }): Promise => { @@ -3404,6 +3460,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF id: archiveId, title: archiveTitle, prompt: promptText, + generationMode: params.generationMode, brief: { ...params.brief }, slots: DETAIL_SLOT_CONFIGS.map((_, index) => { const slot = params.slots[index]; @@ -3438,6 +3495,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const loadDetailArchive = useCallback((archive: DetailArchive) => { setIsDetailGenerationMode(true); + setDetailGenerationMode(normalizeDetailGenerationMode(archive.generationMode)); setDetailBrief({ ...archive.brief }); setDetailSlots( DETAIL_SLOT_CONFIGS.map((_, index) => { @@ -3539,6 +3597,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF `${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`; const archiveSnapshot = { prompt: detailPrompt, + generationMode: detailGenerationMode, brief: { ...detailBrief }, slots: DETAIL_SLOT_CONFIGS.map((_, index) => { const slot = detailSlots[index]; @@ -3555,6 +3614,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const assistantMessageId = generateUUID(); const skillLogicPreview = [ "### AI详情生成", + detailGenerationMode === "agent_layout" + ? "当前模式:AGENT排版(画面与文字分层生成)" + : "当前模式:传统生图(整页直接生成)", "我们将基于《详情升级文案方案》执行以下技能流程:", "1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果", "2. 需求拆解:卖点转买点,确定核心需求优先级", @@ -3599,6 +3661,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF modelImageHint, bundleImageHint, `输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`, + `生成模式:${detailGenerationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页出图)"}`, `字体规范:${DETAIL_TYPOGRAPHY_LOCK.title};${DETAIL_TYPOGRAPHY_LOCK.subtitle};${DETAIL_TYPOGRAPHY_LOCK.body}`, ] .filter(Boolean) @@ -3721,6 +3784,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageCount: 0, coverUrl: undefined, prompt: archiveSnapshot.prompt, + generationMode: archiveSnapshot.generationMode, brief: archiveSnapshot.brief, slots: archiveSnapshot.slots, }); @@ -3776,6 +3840,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF body: JSON.stringify({ prompt: detailPrompt, count: detailCount, + generationMode: detailGenerationMode, referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 12), referenceImageNotes, modelImageUrl: modelAttachment.url, @@ -3791,6 +3856,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } const planData = (planResult.data || {}) as DetailPlanResponse; + const activeDetailGenerationMode = normalizeDetailGenerationMode( + planData.generationMode || detailGenerationMode + ); + const perScreenSteps = activeDetailGenerationMode === "agent_layout" ? 1 : 2; const prompts = Array.isArray(planData.prompts) ? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean) : []; @@ -3804,7 +3873,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF language: outputLanguage, count: DETAIL_REVIEW_COUNT, }); - const totalSteps = prompts.length * 2 + 1 + reviewPlans.length; + const totalSteps = prompts.length * perScreenSteps + 1 + reviewPlans.length; const qualityIssues = Array.isArray(planData.qualityChecks?.issues) ? planData.qualityChecks?.issues.filter(Boolean) : []; @@ -3837,6 +3906,110 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if (detailPlaceholderNodeIds.length > prompts.length) { markExtraPlaceholders(prompts.length, "本轮未使用"); } + const isAgentLayoutMode = activeDetailGenerationMode === "agent_layout"; + const titleFontSize = Math.max( + 36, + Math.min( + 80, + extractFirstNumber(planData.globalStyle?.typography?.title || "", 64) + ) + ); + const subtitleFontSize = Math.max( + 24, + Math.min( + 50, + extractFirstNumber(planData.globalStyle?.typography?.subtitle || "", 34) + ) + ); + const bodyFontSize = Math.max( + 18, + Math.min( + 36, + extractFirstNumber(planData.globalStyle?.typography?.body || "", 24) + ) + ); + const titleFontWeight = extractFontWeight(planData.globalStyle?.typography?.title || "", "700"); + const subtitleFontWeight = extractFontWeight( + planData.globalStyle?.typography?.subtitle || "", + "600" + ); + const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500"); + const createAgentLayoutTextLayers = (params: { + nodeId: string; + screenIndex: number; + screen?: DetailPlanScreenSpec; + }) => { + if (!isAgentLayoutMode) return; + const storeState = useCanvasStore.getState(); + const imageNode = storeState.getNode(params.nodeId); + if (!imageNode) return; + const x = Number(imageNode.position?.x || 0); + const y = Number(imageNode.position?.y || 0); + const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH); + const safeLeft = Math.round(width * 0.07); + const textWidth = Math.max(240, Math.round(width - safeLeft * 2)); + const screen = params.screen; + const titleText = String(screen?.title || "").trim(); + const subtitleText = String(screen?.subtitle || "").trim(); + const bodyText = String(screen?.copy || "").trim(); + + if (titleText) { + addNode({ + type: "text", + position: { x: x + safeLeft, y: y + 72 }, + size: { width: textWidth, height: 140 }, + data: { + content: titleText, + fontSize: params.screenIndex === 0 ? titleFontSize : Math.round(titleFontSize * 0.86), + fontWeight: titleFontWeight, + color: "#111827", + lineHeight: 1.15, + textAlign: "left", + transparent: true, + }, + connections: [], + parentId: params.nodeId, + }); + } + + if (subtitleText) { + addNode({ + type: "text", + position: { x: x + safeLeft, y: y + 214 }, + size: { width: textWidth, height: 120 }, + data: { + content: subtitleText, + fontSize: subtitleFontSize, + fontWeight: subtitleFontWeight, + color: "#374151", + lineHeight: 1.22, + textAlign: "left", + transparent: true, + }, + connections: [], + parentId: params.nodeId, + }); + } + + if (bodyText) { + addNode({ + type: "text", + position: { x: x + safeLeft, y: y + 304 }, + size: { width: textWidth, height: 260 }, + data: { + content: bodyText, + fontSize: bodyFontSize, + fontWeight: bodyFontWeight, + color: "#4b5563", + lineHeight: 1.38, + textAlign: "left", + transparent: true, + }, + connections: [], + parentId: params.nodeId, + }); + } + }; for (let index = 0; index < prompts.length; index += 1) { const screen = screenSpecs[index]; patchDetailPlaceholder(index, { @@ -3852,6 +4025,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF detailPlanSummary: planData.summary || "", detailGlobalStyle: planData.globalStyle || null, detailPreflight: planData.preflight || null, + detailGenerationMode: activeDetailGenerationMode, detailGeneratingPlaceholder: true, }, }); @@ -3878,7 +4052,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const bundleReason = String((screen as any)?.bundleReason || "").trim(); const promptText = String(screen?.visualPrompt || "").trim() || prompts[index]!; - const generationStepCurrent = 2 + index * 2; + const generationStepCurrent = 2 + index * perScreenSteps; const reviewStepCurrent = generationStepCurrent + 1; patchDetailPlaceholder(index, { label: `主图首稿生成中 ${index + 1}/${prompts.length}`, @@ -3922,7 +4096,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF parts: [ { type: "text", - text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`, + text: isAgentLayoutMode + ? `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏画面层,随后自动渲染可编辑文字层...` + : `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`, }, ], } @@ -3947,16 +4123,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。" : "", bundleReason ? `搭配依据:${bundleReason}` : "", - screen?.title ? `本屏标题:${screen.title}` : "", - screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", - screen?.copy ? `本屏文案说明:${screen.copy}` : "", + isAgentLayoutMode + ? "生成策略:仅生成画面层(产品、光影、材质、场景),不要生成任何文字、数字、Logo、标语、水印。" + : screen?.title + ? `本屏标题:${screen.title}` + : "", + isAgentLayoutMode ? "" : screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", + isAgentLayoutMode ? "" : screen?.copy ? `本屏文案说明:${screen.copy}` : "", screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "", screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "", screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "", screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "", screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "", `输出为竖版电商详情图,尺寸固定 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素。`, - "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。", + isAgentLayoutMode + ? "硬性约束:禁止输出任何可见文字或字符,排版文字由后续程序化图层渲染。" + : "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。", ] .filter(Boolean) .join("\n\n"); @@ -3999,75 +4181,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF let finalGenerated = firstPassImage; let finalGenerationPrompt = generationPrompt; let screenReviewResult: DetailScreenReviewResult | null = null; - patchDetailPlaceholder(index, { - label: `双总监审稿中 ${index + 1}/${prompts.length}`, - status: "generating", - }); - - setDetailProgress({ - status: "running", - stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, - current: reviewStepCurrent, - total: totalSteps, - }); - setMessages((prev) => - prev.map((message) => - message.id === assistantMessageId - ? { - ...message, - parts: [ - { - type: "text", - text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`, - }, - ], - } - : message, - ), - ); - - try { - const reviewResponse = await fetch("/api/canvas/detail-review", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - outputLanguage, - imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl, - referenceImages: generationReferenceImagesBase.slice(0, 6), - brief, - globalStyle: planData.globalStyle, - screen: { - index: index + 1, - title: screen?.title || "", - subtitle: screen?.subtitle || "", - copy: screen?.copy || "", - layoutNote: screen?.layoutNote || "", - visualPrompt: generationPrompt, - screenType: screen?.screenType || "", - }, - }), - }); - const reviewResult = await reviewResponse.json().catch(() => ({})); - if (reviewResponse.ok && reviewResult?.success) { - screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult; - } - } catch (reviewError) { - console.error("[CanvasChat] Detail screen review failed:", reviewError); - } - - const shouldRegenerate = - screenReviewResult?.pass === false && - typeof screenReviewResult?.revisedPrompt === "string" && - screenReviewResult.revisedPrompt.trim().length > 20; - - if (shouldRegenerate) { + if (!isAgentLayoutMode) { patchDetailPlaceholder(index, { - label: `二次优化生成中 ${index + 1}/${prompts.length}`, + label: `双总监审稿中 ${index + 1}/${prompts.length}`, status: "generating", }); - const issueHint = Array.isArray(screenReviewResult?.issues) - ? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";") - : ""; + + setDetailProgress({ + status: "running", + stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, + current: reviewStepCurrent, + total: totalSteps, + }); setMessages((prev) => prev.map((message) => message.id === assistantMessageId @@ -4076,7 +4201,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF parts: [ { type: "text", - text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`, + text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`, }, ], } @@ -4084,21 +4209,85 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ), ); - for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) { - try { - const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim(); - const regenerated = await generateDetailFrame(revisedPrompt, [ - firstPassImage.originalUrl || firstPassImage.thumbnailUrl, - ]); - finalGenerated = regenerated; - finalGenerationPrompt = revisedPrompt; - break; - } catch (regenError) { - if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) { - console.error("[CanvasChat] Detail second-pass generation failed:", regenError); + try { + const reviewResponse = await fetch("/api/canvas/detail-review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + outputLanguage, + imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl, + referenceImages: generationReferenceImagesBase.slice(0, 6), + brief, + globalStyle: planData.globalStyle, + screen: { + index: index + 1, + title: screen?.title || "", + subtitle: screen?.subtitle || "", + copy: screen?.copy || "", + layoutNote: screen?.layoutNote || "", + visualPrompt: generationPrompt, + screenType: screen?.screenType || "", + }, + }), + }); + const reviewResult = await reviewResponse.json().catch(() => ({})); + if (reviewResponse.ok && reviewResult?.success) { + screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult; + } + } catch (reviewError) { + console.error("[CanvasChat] Detail screen review failed:", reviewError); + } + + const shouldRegenerate = + screenReviewResult?.pass === false && + typeof screenReviewResult?.revisedPrompt === "string" && + screenReviewResult.revisedPrompt.trim().length > 20; + + if (shouldRegenerate) { + patchDetailPlaceholder(index, { + label: `二次优化生成中 ${index + 1}/${prompts.length}`, + status: "generating", + }); + const issueHint = Array.isArray(screenReviewResult?.issues) + ? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";") + : ""; + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + parts: [ + { + type: "text", + text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`, + }, + ], + } + : message, + ), + ); + + for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) { + try { + const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim(); + const regenerated = await generateDetailFrame(revisedPrompt, [ + firstPassImage.originalUrl || firstPassImage.thumbnailUrl, + ]); + finalGenerated = regenerated; + finalGenerationPrompt = revisedPrompt; + break; + } catch (regenError) { + if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) { + console.error("[CanvasChat] Detail second-pass generation failed:", regenError); + } } } } + } else { + patchDetailPlaceholder(index, { + label: `排版层渲染中 ${index + 1}/${prompts.length}`, + status: "generating", + }); } generatedImages.push(finalGenerated); @@ -4159,12 +4348,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF detailBundleShowcase: bundleShowcase, detailBundleReason: bundleReason, detailGlobalStyle: planData.globalStyle || null, + detailGenerationMode: activeDetailGenerationMode, detailScreenReview: screenReviewResult || null, detailGeneratingPlaceholder: false, detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`, }, }, }); + createAgentLayoutTextLayers({ + nodeId: targetNodeId, + screenIndex: index, + screen, + }); } } if (generatedImages.length !== prompts.length) { @@ -4289,6 +4484,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } const finalMarkdown = buildDetailResultMarkdown({ + generationMode: activeDetailGenerationMode, summary: planData.summary, images: generatedImages, prompts, @@ -4347,6 +4543,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF imageCount: generatedImages.length, coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl, prompt: archiveSnapshot.prompt, + generationMode: archiveSnapshot.generationMode, brief: archiveSnapshot.brief, slots: archiveSnapshot.slots, }); @@ -4429,6 +4626,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF addNode, clearSelection, detailBrief, + detailGenerationMode, detailSlots, imageParams.negativePrompt, input, @@ -4764,7 +4962,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF } if (!input.trim() && attachments.length === 0) return; - if (!selectedModelId) { + const effectiveModelId = currentModelIdRef.current || selectedModelId; + if (!effectiveModelId) { toast.error("请选择一个模型"); return; } @@ -4883,6 +5082,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF parts.push({ type: "text" as const, text: textContent }); } + // 若 ref 为空才回填,避免覆盖“刚切换但状态尚未刷新的新模型值” + if (!currentModelIdRef.current) { + currentModelIdRef.current = effectiveModelId; + } + // 使用与 multimodal-input 相同的格式发送消息 sendMessage({ role: "user", @@ -6226,6 +6430,43 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF )} +
+
+

生成路线

+ + {detailGenerationMode === "classic" ? "当前:传统生图" : "当前:AGENT排版"} + +
+
+ {DETAIL_GENERATION_MODE_OPTIONS.map((option) => { + const selected = detailGenerationMode === option.value; + return ( + + ); + })} +
+
+
{/* Required Info */} {new Date(archive.createdAt).toLocaleString()} {archive.imageCount || 0}张输出 + + {archive.generationMode === "agent_layout" ? "AGENT排版" : "传统生图"}
@@ -7127,6 +7370,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF