From 3e4594273b09509b6e18085594aa63a9c0d3996c Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Mar 2026 19:30:57 +0800 Subject: [PATCH] feat: add detail ui protocol foundation --- app/api/canvas/detail-plan/route.ts | 60 ++++- app/api/canvas/detail-review/route.ts | 97 ++++++++ components/canvas/CanvasChatPanel.tsx | 344 +++++++++++++++----------- lib/detail-ui-protocol.ts | 301 ++++++++++++++++++++++ lib/detail-ui-renderer.ts | 175 +++++++++++++ lib/detail-ui-validator.ts | 149 +++++++++++ 6 files changed, 981 insertions(+), 145 deletions(-) create mode 100644 lib/detail-ui-protocol.ts create mode 100644 lib/detail-ui-renderer.ts create mode 100644 lib/detail-ui-validator.ts diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 70260dd0e..f5ca5bad4 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -12,6 +12,11 @@ import { import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; import { filterReachableRemoteUrls } from "@/lib/net/url-access"; +import { + buildDetailUiProtocol, + type DetailUiProtocol, +} from "@/lib/detail-ui-protocol"; +import { validateDetailUiProtocol } from "@/lib/detail-ui-validator"; export const runtime = "nodejs"; export const maxDuration = 180; @@ -218,6 +223,10 @@ type DetailPlanResponse = { passed: boolean; issues: string[]; }; + uiValidation?: { + passed: boolean; + issues: string[]; + }; preflight?: { passed: boolean; designDirectorScore: number; @@ -226,6 +235,7 @@ type DetailPlanResponse = { rewriteApplied: boolean; }; screens: DetailPlanScreen[]; + uiProtocol?: DetailUiProtocol; model?: { id: string; displayName: string; @@ -1190,6 +1200,20 @@ function buildFallbackPlan(params: { hasBundleImages: params.hasBundleImages, coreFocus: params.coreFocus, }); + const uiProtocol = buildDetailUiProtocol({ + generationMode: params.generationMode, + screenSize: { + width: 790, + height: 1300, + }, + globalStyle, + screens, + }); + const uiValidation = validateDetailUiProtocol(uiProtocol); + const mergedIssues = [ + ...qualityChecks.issues, + ...uiValidation.issues.map((item) => item.message), + ]; return { generationMode: params.generationMode, summary: @@ -1203,8 +1227,16 @@ function buildFallbackPlan(params: { }, globalStyle, skillFlow, - qualityChecks, + qualityChecks: { + passed: qualityChecks.passed && uiValidation.passed, + issues: mergedIssues, + }, + uiValidation: { + passed: uiValidation.passed, + issues: uiValidation.issues.map((item) => item.message), + }, screens, + uiProtocol, }; } @@ -2065,6 +2097,16 @@ export async function POST(request: Request) { ...preflight.issues.map((issue) => `双总监预审:${issue}`), ]), ]; + const uiProtocol = buildDetailUiProtocol({ + generationMode, + screenSize: { + width: 790, + height: 1300, + }, + globalStyle, + screens, + }); + const uiValidation = validateDetailUiProtocol(uiProtocol); const qualityChecks = { passed: !shouldFallback, issues: shouldFallback @@ -2073,6 +2115,10 @@ export async function POST(request: Request) { : ["双总监预审未通过,已回退到稳定分镜模板。"] : qualityIssuesMerged, }; + const mergedIssues = [ + ...qualityChecks.issues, + ...uiValidation.issues.map((item) => item.message), + ]; const prompts = screens.map((item) => item.visualPrompt); const summaryRaw = String(parsed?.summary || "").trim(); const summary = desensitizeAudienceCopy( @@ -2093,11 +2139,18 @@ export async function POST(request: Request) { globalStyle, skillFlow: fallback.skillFlow, qualityChecks: qualityChecks.passed - ? qualityChecks + ? { + passed: qualityChecks.passed && uiValidation.passed, + issues: mergedIssues, + } : { passed: false, - issues: [...qualityChecks.issues], + issues: [...mergedIssues], }, + uiValidation: { + passed: uiValidation.passed, + issues: uiValidation.issues.map((item) => item.message), + }, preflight: { passed: !shouldFallback, designDirectorScore: preflight.designDirectorScore, @@ -2106,6 +2159,7 @@ export async function POST(request: Request) { rewriteApplied: preflight.rewriteApplied, }, screens, + uiProtocol, model: { id: plannerConfig.model.id, displayName: plannerConfig.model.displayName, diff --git a/app/api/canvas/detail-review/route.ts b/app/api/canvas/detail-review/route.ts index 45b537c85..e8f8c6c42 100644 --- a/app/api/canvas/detail-review/route.ts +++ b/app/api/canvas/detail-review/route.ts @@ -11,12 +11,20 @@ import { } from "@/lib/db/ai-model-flags"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { inlineVisionImages } from "@/lib/ai/vision-inline"; +import { + buildDetailUiProtocol, + buildDetailUiReviewPatch, + type DetailGenerationMode, + type DetailUiScreenReviewPatch, +} from "@/lib/detail-ui-protocol"; +import { validateDetailUiProtocol } from "@/lib/detail-ui-validator"; export const runtime = "nodejs"; export const maxDuration = 180; type DetailReviewRequest = { outputLanguage?: string; + generationMode?: string; imageUrl?: string; referenceImages?: string[]; screen?: { @@ -168,6 +176,8 @@ export async function POST(request: Request) { const body = (await request.json()) as DetailReviewRequest; const language = normalizeLanguage(body.outputLanguage); + const generationMode: DetailGenerationMode = + body.generationMode === "agent_layout" ? "agent_layout" : "classic"; const imageUrl = String(body.imageUrl || "").trim(); if (!/^https?:\/\//i.test(imageUrl)) { return NextResponse.json({ success: false, error: "缺少待质检图片" }, { status: 400 }); @@ -200,18 +210,45 @@ export async function POST(request: Request) { language, isFirstScreen: Number(body.screen?.index || 0) === 1, }); + const currentUiProtocol = buildDetailUiProtocol({ + generationMode, + screenSize: { + width: 790, + height: 1300, + }, + globalStyle: body.globalStyle, + screens: [ + { + index: Number(body.screen?.index || 1), + title: String(body.screen?.title || "").trim(), + subtitle: String(body.screen?.subtitle || "").trim(), + copy: String(body.screen?.copy || "").trim(), + screenType: + body.screen?.screenType === "A" || + body.screen?.screenType === "B" || + body.screen?.screenType === "C" + ? body.screen.screenType + : undefined, + objective: undefined, + layoutNote: layoutNote, + }, + ], + }); + const currentUiValidation = validateDetailUiProtocol(currentUiProtocol); if (!reviewerConfig) { return NextResponse.json({ success: true, data: { pass: true, + reviewAction: "pass", score: 80, designDirectorScore: 80, marketingDirectorScore: 80, issues: [], layoutFixes: [], revisedPrompt: fallbackPrompt, + uiPatch: undefined, model: null, }, }); @@ -227,12 +264,14 @@ export async function POST(request: Request) { success: true, data: { pass: true, + reviewAction: "pass", score: 80, designDirectorScore: 80, marketingDirectorScore: 80, issues: [], layoutFixes: [], revisedPrompt: fallbackPrompt, + uiPatch: undefined, model: null, }, }); @@ -259,6 +298,13 @@ export async function POST(request: Request) { `- Screen subtitle: ${String(body.screen?.subtitle || "").trim()}`, `- Screen copy: ${String(body.screen?.copy || "").trim()}`, `- Layout note: ${layoutNote}`, + currentUiValidation.issues.length > 0 + ? `- Current protocol validation issues: ${currentUiValidation.issues + .map((item) => item.message) + .join(" | ")}` + : language === "en" + ? "- Current protocol validation issues: none" + : "- 当前协议校验问题:无", Number(body.screen?.index || 0) === 1 ? "- Screen 1 must keep poster hero quality: single dominant focal point, clear headline zone, no collage." : "", @@ -319,12 +365,14 @@ export async function POST(request: Request) { success: true, data: { pass: true, + reviewAction: "pass", score: 78, designDirectorScore: 78, marketingDirectorScore: 78, issues: fallbackIssues, layoutFixes: [], revisedPrompt: fallbackPrompt, + uiPatch: undefined, model: { id: reviewerConfig.model.id, displayName: reviewerConfig.model.displayName, @@ -392,16 +440,65 @@ export async function POST(request: Request) { designDirectorScore >= 85 && marketingDirectorScore >= 85); + const uiPatch: DetailUiScreenReviewPatch | undefined = buildDetailUiReviewPatch({ + generationMode, + screenSize: { + width: 790, + height: 1300, + }, + globalStyle: body.globalStyle, + screen: { + index: Number(body.screen?.index || 1), + title: String(body.screen?.title || "").trim(), + subtitle: String(body.screen?.subtitle || "").trim(), + copy: String(body.screen?.copy || "").trim(), + screenType: + body.screen?.screenType === "A" || + body.screen?.screenType === "B" || + body.screen?.screenType === "C" + ? body.screen.screenType + : undefined, + objective: undefined, + layoutNote: String(body.screen?.layoutNote || "").trim(), + }, + layoutFixes, + typographyFixes, + conversionFixes, + }); + const uiValidation = validateDetailUiProtocol({ + version: "detail-ui-protocol/v1", + mode: generationMode, + screenSize: { + width: 790, + height: 1300, + }, + screens: uiPatch ? [uiPatch.recommendedScreen] : [], + globalStyle: body.globalStyle, + }); + const reviewAction = + pass + ? "pass" + : generationMode === "agent_layout" && + (uiPatch.notes.length > 0 || !uiValidation.passed) + ? "protocol_patch" + : "regenerate_image"; + return NextResponse.json({ success: true, data: { pass, + reviewAction, score, designDirectorScore, marketingDirectorScore, issues, layoutFixes: [...layoutFixes, ...typographyFixes, ...conversionFixes].slice(0, 10), revisedPrompt, + uiPatch, + uiValidation: { + passed: uiValidation.passed, + issues: uiValidation.issues.map((item) => item.message), + }, model: { id: reviewerConfig.model.id, displayName: reviewerConfig.model.displayName, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e8a1a26dd..147d0458f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -5,6 +5,13 @@ import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useSession } from "next-auth/react"; import { createPortal } from "react-dom"; +import type { DetailUiProtocol } from "@/lib/detail-ui-protocol"; +import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol"; +import { + buildDetailTextLayerNodesFromProtocol, + buildDetailTextLayerNodesFromReviewPatch, + buildLegacyDetailTextLayerNodes, +} from "@/lib/detail-ui-renderer"; import useSWR, { useSWRConfig } from "swr"; import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -57,6 +64,7 @@ import type { AgentPreset, AgentWorkflowComposerContext, } from "@/types/agent"; +import type { CanvasNode } from "@/types/canvas"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { DEFAULT_CANVAS_SKILL_TAGS_TITLE, @@ -1235,6 +1243,7 @@ type DetailPlanResponse = { displayName: string; providerType: string; }; + uiProtocol?: DetailUiProtocol; }; type DetailPlanScreenSpec = NonNullable[number]; @@ -1355,10 +1364,12 @@ type DetailReviewGroup = { type DetailScreenReviewResult = { pass: boolean; + reviewAction?: "pass" | "protocol_patch" | "regenerate_image"; score?: number; issues?: string[]; layoutFixes?: string[]; revisedPrompt?: string; + uiPatch?: DetailUiScreenReviewPatch; model?: { id?: string; displayName?: string; @@ -6057,10 +6068,26 @@ export function CanvasChatPanel({ "600" ); const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500"); + const replaceDetailTextLayers = (params: { + nodeId: string; + drafts: Array>; + }) => { + const storeState = useCanvasStore.getState(); + const childTextNodes = storeState.nodes.filter( + (node) => + node.parentId === params.nodeId && + node.type === "text" && + Boolean((node.data as any)?.metadata?.detailUiBlockId), + ); + + childTextNodes.forEach((node) => deleteNode(node.id)); + params.drafts.forEach((draft) => addNode(draft)); + }; const createAgentLayoutTextLayers = (params: { nodeId: string; screenIndex: number; screen?: DetailPlanScreenSpec; + screenProtocol?: DetailUiProtocol["screens"][number]; }) => { if (!isAgentLayoutMode) return; const storeState = useCanvasStore.getState(); @@ -6069,69 +6096,40 @@ export function CanvasChatPanel({ 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 height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT); const screen = params.screen; - const titleText = String(screen?.title || "").trim(); - const subtitleText = String(screen?.subtitle || "").trim(); - const bodyText = String(screen?.copy || "").trim(); + const drafts = + params.screenProtocol + ? buildDetailTextLayerNodesFromProtocol({ + parentNodeId: params.nodeId, + parentPosition: { x, y }, + parentSize: { width, height }, + screenSize: planData.uiProtocol?.screenSize || { + width: DETAIL_TARGET_WIDTH, + height: DETAIL_TARGET_HEIGHT, + }, + screenProtocol: params.screenProtocol, + }) + : buildLegacyDetailTextLayerNodes({ + parentNodeId: params.nodeId, + parentPosition: { x, y }, + parentWidth: width, + screenIndex: params.screenIndex, + title: screen?.title, + subtitle: screen?.subtitle, + body: screen?.copy, + titleFontSize, + titleFontWeight, + subtitleFontSize, + subtitleFontWeight, + bodyFontSize, + bodyFontWeight, + }); - 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, - }); - } + replaceDetailTextLayers({ + nodeId: params.nodeId, + drafts, + }); }; for (let index = 0; index < prompts.length; index += 1) { const screen = screenSpecs[index]; @@ -6306,18 +6304,88 @@ export function CanvasChatPanel({ let finalGenerated = firstPassImage; let finalGenerationPrompt = generationPrompt; let screenReviewResult: DetailScreenReviewResult | null = null; - if (!isAgentLayoutMode) { + const targetNodeId = detailPlaceholderNodeIds[index]; + patchDetailPlaceholder(index, { + label: isAgentLayoutMode + ? `协议质检中 ${index + 1}/${prompts.length}` + : `双总监审稿中 ${index + 1}/${prompts.length}`, + status: "generating", + }); + + setDetailProgress({ + status: "running", + stage: isAgentLayoutMode + ? `第 ${index + 1}/${prompts.length} 屏协议质检中,Agent 正在检查背景与文字层协议...` + : `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, + current: reviewStepCurrent, + total: totalSteps, + }); + setMessages((prev) => + prev.map((message) => + message.id === assistantMessageId + ? { + ...message, + parts: [ + { + type: "text", + text: isAgentLayoutMode + ? `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在检查背景画面与协议文字层...` + : `### 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, + generationMode: activeDetailGenerationMode, + 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 shouldApplyProtocolPatch = Boolean( + screenReviewResult?.uiPatch && + screenReviewResult?.reviewAction === "protocol_patch", + ); + const shouldRegenerate = + !shouldApplyProtocolPatch && + screenReviewResult?.reviewAction === "regenerate_image" && + typeof screenReviewResult?.revisedPrompt === "string" && + screenReviewResult.revisedPrompt.trim().length > 20; + + if (shouldApplyProtocolPatch) { patchDetailPlaceholder(index, { - label: `双总监审稿中 ${index + 1}/${prompts.length}`, + label: `协议修正中 ${index + 1}/${prompts.length}`, status: "generating", }); - - setDetailProgress({ - status: "running", - stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, - current: reviewStepCurrent, - total: totalSteps, - }); + const issueHint = Array.isArray(screenReviewResult?.issues) + ? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";") + : ""; setMessages((prev) => prev.map((message) => message.id === assistantMessageId @@ -6326,7 +6394,7 @@ export function CanvasChatPanel({ parts: [ { type: "text", - text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`, + text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现排版/文案层可优化${issueHint ? `:${issueHint}` : ""},正在直接修正协议文字层...`, }, ], } @@ -6334,83 +6402,75 @@ export function CanvasChatPanel({ ), ); - 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 currentNode = targetNodeId + ? useCanvasStore.getState().getNode(targetNodeId) + : null; + if (currentNode && targetNodeId) { + const replacementDrafts = buildDetailTextLayerNodesFromReviewPatch({ + parentNodeId: targetNodeId, + parentPosition: { + x: Number(currentNode.position?.x || 0), + y: Number(currentNode.position?.y || 0), + }, + parentSize: { + width: Number(currentNode.size?.width || DETAIL_TARGET_WIDTH), + height: Number(currentNode.size?.height || DETAIL_TARGET_HEIGHT), + }, + screenSize: planData.uiProtocol?.screenSize || { + width: DETAIL_TARGET_WIDTH, + height: DETAIL_TARGET_HEIGHT, + }, + uiPatch: screenReviewResult?.uiPatch, }); - const reviewResult = await reviewResponse.json().catch(() => ({})); - if (reviewResponse.ok && reviewResult?.success) { - screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult; + if (replacementDrafts.length > 0) { + replaceDetailTextLayers({ + nodeId: targetNodeId, + drafts: replacementDrafts, + }); } - } catch (reviewError) { - console.error("[CanvasChat] Detail screen review failed:", reviewError); } + } else 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, + ), + ); - 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 = sanitizeDetailImageModelPrompt( - 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); - } + for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) { + try { + const revisedPrompt = sanitizeDetailImageModelPrompt( + 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 { + } else if (isAgentLayoutMode) { patchDetailPlaceholder(index, { label: `排版层渲染中 ${index + 1}/${prompts.length}`, status: "generating", @@ -6441,7 +6501,6 @@ export function CanvasChatPanel({ const dimensions = await getImageDimensions( finalGenerated.originalUrl || finalGenerated.thumbnailUrl ); - const targetNodeId = detailPlaceholderNodeIds[index]; if (targetNodeId) { const storeState = useCanvasStore.getState(); const currentNode = storeState.getNode(targetNodeId); @@ -6486,6 +6545,7 @@ export function CanvasChatPanel({ nodeId: targetNodeId, screenIndex: index, screen, + screenProtocol: planData.uiProtocol?.screens?.[index], }); } } diff --git a/lib/detail-ui-protocol.ts b/lib/detail-ui-protocol.ts new file mode 100644 index 000000000..12ea86b7a --- /dev/null +++ b/lib/detail-ui-protocol.ts @@ -0,0 +1,301 @@ +export type DetailGenerationMode = "classic" | "agent_layout"; + +export type DetailUiTextBlockType = "title" | "subtitle" | "body"; + +export type DetailUiTextAlign = "left" | "center" | "right"; + +export interface DetailUiGlobalStyle { + designLanguage?: string; + typography?: { + fontFamily?: string; + title?: string; + subtitle?: string; + body?: string; + }; + layout?: { + grid?: string; + safeArea?: string; + rhythm?: string; + }; + palette?: string; +} + +export interface DetailUiScreenInput { + index: number; + title?: string; + subtitle?: string; + copy?: string; + screenType?: "A" | "B" | "C"; + objective?: string; + layoutNote?: string; +} + +export interface DetailUiFrame { + x: number; + y: number; + width: number; + height: number; +} + +export interface DetailUiTextStyle { + fontFamily?: string; + fontSize: number; + fontWeight: string; + lineHeight: number; + color: string; + textAlign: DetailUiTextAlign; +} + +export interface DetailUiTextBlock { + id: string; + type: DetailUiTextBlockType; + content: string; + frame: DetailUiFrame; + style: DetailUiTextStyle; +} + +export interface DetailUiScreenProtocol { + index: number; + screenType?: "A" | "B" | "C"; + objective?: string; + layoutNote?: string; + safeArea: { + top: number; + bottom: number; + left: number; + right: number; + }; + blocks: DetailUiTextBlock[]; +} + +export interface DetailUiProtocol { + version: "detail-ui-protocol/v1"; + mode: DetailGenerationMode; + screenSize: { + width: number; + height: number; + }; + globalStyle?: DetailUiGlobalStyle; + screens: DetailUiScreenProtocol[]; +} + +export interface DetailUiPatchNote { + type: "layout" | "typography" | "conversion"; + message: string; +} + +export interface DetailUiScreenReviewPatch { + version: "detail-ui-review-patch/v1"; + mode: DetailGenerationMode; + screenIndex: number; + recommendedScreen: DetailUiScreenProtocol; + notes: DetailUiPatchNote[]; +} + +function extractFirstNumber(value: string, fallback: number): number { + const matched = String(value || "").match(/(\d+(?:\.\d+)?)/); + return matched ? Number(matched[1]) : fallback; +} + +function extractFontWeight(value: string, fallback = "600"): string { + const matched = String(value || "").match(/\b(100|200|300|400|500|600|700|800|900)\b/); + return matched?.[1] || fallback; +} + +function parseSafeArea(input?: string, width = 790, height = 1300) { + const raw = String(input || ""); + const yMatch = raw.match(/y\s*=\s*(\d+)\s*[-~~]\s*(\d+)/i); + const leftMatch = raw.match(/(?:左右边距|左边距|左侧边距)\s*(\d+)\s*px/i); + const rightMatch = raw.match(/(?:左右边距|右边距|右侧边距)\s*(\d+)\s*px/i); + + const top = yMatch ? Number(yMatch[1]) : 64; + const bottom = yMatch ? Number(yMatch[2]) : Math.min(height - 120, 360); + const left = leftMatch ? Number(leftMatch[1]) : 56; + const right = rightMatch ? Number(rightMatch[1]) : left; + + return { + top, + bottom, + left: Math.max(24, Math.min(width / 3, left)), + right: Math.max(24, Math.min(width / 3, right)), + }; +} + +function buildTextBlockStyle( + type: DetailUiTextBlockType, + globalStyle?: DetailUiGlobalStyle, + screenIndex = 0, +): DetailUiTextStyle { + const typography = globalStyle?.typography; + const titleBase = extractFirstNumber(typography?.title || "", 64); + const subtitleBase = extractFirstNumber(typography?.subtitle || "", 34); + const bodyBase = extractFirstNumber(typography?.body || "", 24); + + if (type === "title") { + const fontSize = Math.max(36, Math.min(80, screenIndex === 0 ? titleBase : Math.round(titleBase * 0.86))); + return { + fontFamily: typography?.fontFamily, + fontSize, + fontWeight: extractFontWeight(typography?.title || "", "700"), + lineHeight: 1.15, + color: "#111827", + textAlign: "left", + }; + } + + if (type === "subtitle") { + return { + fontFamily: typography?.fontFamily, + fontSize: Math.max(24, Math.min(50, subtitleBase)), + fontWeight: extractFontWeight(typography?.subtitle || "", "600"), + lineHeight: 1.22, + color: "#374151", + textAlign: "left", + }; + } + + return { + fontFamily: typography?.fontFamily, + fontSize: Math.max(18, Math.min(36, bodyBase)), + fontWeight: extractFontWeight(typography?.body || "", "500"), + lineHeight: 1.38, + color: "#4b5563", + textAlign: "left", + }; +} + +function estimateBlockHeight( + content: string, + style: DetailUiTextStyle, + width: number, + maxLines: number, +) { + const safeWidth = Math.max(120, width); + const charsPerLine = Math.max(8, Math.floor(safeWidth / Math.max(10, style.fontSize * 0.9))); + const estimatedLines = Math.min( + maxLines, + Math.max(1, Math.ceil(Array.from(String(content || "")).length / charsPerLine)), + ); + return Math.ceil(style.fontSize * style.lineHeight * estimatedLines + 16); +} + +export function buildDetailUiProtocol(params: { + generationMode: DetailGenerationMode; + screenSize: { width: number; height: number }; + globalStyle?: DetailUiGlobalStyle; + screens: DetailUiScreenInput[]; +}): DetailUiProtocol { + const { generationMode, screenSize, globalStyle } = params; + + const screens = params.screens.map((screen, index) => { + const safeArea = parseSafeArea(globalStyle?.layout?.safeArea, screenSize.width, screenSize.height); + const textWidth = Math.max(240, Math.round(screenSize.width - safeArea.left - safeArea.right)); + const blocks: DetailUiTextBlock[] = []; + + const titleText = String(screen.title || "").trim(); + const subtitleText = String(screen.subtitle || "").trim(); + const bodyText = String(screen.copy || "").trim(); + + let cursorY = safeArea.top; + + if (titleText) { + const style = buildTextBlockStyle("title", globalStyle, index); + const height = estimateBlockHeight(titleText, style, textWidth, 2); + blocks.push({ + id: `screen-${screen.index}-title`, + type: "title", + content: titleText, + frame: { + x: safeArea.left, + y: cursorY, + width: textWidth, + height, + }, + style, + }); + cursorY += height + 16; + } + + if (subtitleText) { + const style = buildTextBlockStyle("subtitle", globalStyle, index); + const height = estimateBlockHeight(subtitleText, style, textWidth, 3); + blocks.push({ + id: `screen-${screen.index}-subtitle`, + type: "subtitle", + content: subtitleText, + frame: { + x: safeArea.left, + y: cursorY, + width: textWidth, + height, + }, + style, + }); + cursorY += height + 14; + } + + if (bodyText) { + const style = buildTextBlockStyle("body", globalStyle, index); + const remainingHeight = Math.max(96, safeArea.bottom - cursorY); + const estimatedHeight = estimateBlockHeight(bodyText, style, textWidth, 5); + blocks.push({ + id: `screen-${screen.index}-body`, + type: "body", + content: bodyText, + frame: { + x: safeArea.left, + y: cursorY, + width: textWidth, + height: Math.min(remainingHeight, estimatedHeight), + }, + style, + }); + } + + return { + index: screen.index, + screenType: screen.screenType, + objective: screen.objective, + layoutNote: screen.layoutNote, + safeArea, + blocks, + } satisfies DetailUiScreenProtocol; + }); + + return { + version: "detail-ui-protocol/v1", + mode: generationMode, + screenSize, + globalStyle, + screens, + }; +} + +export function buildDetailUiReviewPatch(params: { + generationMode: DetailGenerationMode; + screenSize: { width: number; height: number }; + globalStyle?: DetailUiGlobalStyle; + screen: DetailUiScreenInput; + layoutFixes?: string[]; + typographyFixes?: string[]; + conversionFixes?: string[]; +}): DetailUiScreenReviewPatch { + const protocol = buildDetailUiProtocol({ + generationMode: params.generationMode, + screenSize: params.screenSize, + globalStyle: params.globalStyle, + screens: [params.screen], + }); + + return { + version: "detail-ui-review-patch/v1", + mode: params.generationMode, + screenIndex: params.screen.index, + recommendedScreen: protocol.screens[0]!, + notes: [ + ...(params.layoutFixes || []).map((message) => ({ type: "layout" as const, message })), + ...(params.typographyFixes || []).map((message) => ({ type: "typography" as const, message })), + ...(params.conversionFixes || []).map((message) => ({ type: "conversion" as const, message })), + ], + }; +} diff --git a/lib/detail-ui-renderer.ts b/lib/detail-ui-renderer.ts new file mode 100644 index 000000000..08ee44e7e --- /dev/null +++ b/lib/detail-ui-renderer.ts @@ -0,0 +1,175 @@ +import type { + DetailUiProtocol, + DetailUiScreenReviewPatch, +} from "@/lib/detail-ui-protocol"; +import type { CanvasNode, TextNodeData } from "@/types/canvas"; + +type CanvasTextNodeDraft = Omit; + +function createTextNodeDraft(params: { + parentId: string; + x: number; + y: number; + width: number; + height: number; + content: string; + fontSize: number; + fontWeight: string; + color: string; + lineHeight: number; + textAlign: "left" | "center" | "right"; + metadata?: Record; +}): CanvasTextNodeDraft { + return { + type: "text", + position: { x: params.x, y: params.y }, + size: { width: params.width, height: params.height }, + data: { + content: params.content, + fontSize: params.fontSize, + fontWeight: params.fontWeight, + color: params.color, + lineHeight: params.lineHeight, + textAlign: params.textAlign, + transparent: true, + metadata: params.metadata, + } as TextNodeData & { metadata?: Record }, + connections: [], + parentId: params.parentId, + }; +} + +export function buildDetailTextLayerNodesFromProtocol(params: { + parentNodeId: string; + parentPosition: { x: number; y: number }; + parentSize: { width: number; height: number }; + screenSize: { width: number; height: number }; + screenProtocol?: DetailUiProtocol["screens"][number]; +}) { + const protocolBlocks = params.screenProtocol?.blocks || []; + if (protocolBlocks.length === 0) return [] as CanvasTextNodeDraft[]; + + const scaleX = params.parentSize.width / Math.max(1, params.screenSize.width); + const scaleY = params.parentSize.height / Math.max(1, params.screenSize.height); + + return protocolBlocks.map((block) => + createTextNodeDraft({ + parentId: params.parentNodeId, + x: params.parentPosition.x + Math.round(block.frame.x * scaleX), + y: params.parentPosition.y + Math.round(block.frame.y * scaleY), + width: Math.max(120, Math.round(block.frame.width * scaleX)), + height: Math.max(48, Math.round(block.frame.height * scaleY)), + content: block.content, + fontSize: Math.max(12, Math.round(block.style.fontSize * Math.min(scaleX, scaleY))), + fontWeight: block.style.fontWeight, + color: block.style.color, + lineHeight: block.style.lineHeight, + textAlign: block.style.textAlign, + metadata: { + detailUiBlockId: block.id, + detailUiBlockType: block.type, + detailUiScreenIndex: params.screenProtocol?.index, + }, + }), + ); +} + +export function buildDetailTextLayerNodesFromReviewPatch(params: { + parentNodeId: string; + parentPosition: { x: number; y: number }; + parentSize: { width: number; height: number }; + screenSize: { width: number; height: number }; + uiPatch?: DetailUiScreenReviewPatch; +}) { + if (!params.uiPatch) return [] as CanvasTextNodeDraft[]; + return buildDetailTextLayerNodesFromProtocol({ + parentNodeId: params.parentNodeId, + parentPosition: params.parentPosition, + parentSize: params.parentSize, + screenSize: params.screenSize, + screenProtocol: params.uiPatch.recommendedScreen, + }); +} + +export function buildLegacyDetailTextLayerNodes(params: { + parentNodeId: string; + parentPosition: { x: number; y: number }; + parentWidth: number; + screenIndex: number; + title?: string; + subtitle?: string; + body?: string; + titleFontSize: number; + titleFontWeight: string; + subtitleFontSize: number; + subtitleFontWeight: string; + bodyFontSize: number; + bodyFontWeight: string; +}) { + const safeLeft = Math.round(params.parentWidth * 0.07); + const textWidth = Math.max(240, Math.round(params.parentWidth - safeLeft * 2)); + const drafts: CanvasTextNodeDraft[] = []; + + const titleText = String(params.title || "").trim(); + const subtitleText = String(params.subtitle || "").trim(); + const bodyText = String(params.body || "").trim(); + + if (titleText) { + drafts.push( + createTextNodeDraft({ + parentId: params.parentNodeId, + x: params.parentPosition.x + safeLeft, + y: params.parentPosition.y + 72, + width: textWidth, + height: 140, + content: titleText, + fontSize: + params.screenIndex === 0 + ? params.titleFontSize + : Math.round(params.titleFontSize * 0.86), + fontWeight: params.titleFontWeight, + color: "#111827", + lineHeight: 1.15, + textAlign: "left", + }), + ); + } + + if (subtitleText) { + drafts.push( + createTextNodeDraft({ + parentId: params.parentNodeId, + x: params.parentPosition.x + safeLeft, + y: params.parentPosition.y + 214, + width: textWidth, + height: 120, + content: subtitleText, + fontSize: params.subtitleFontSize, + fontWeight: params.subtitleFontWeight, + color: "#374151", + lineHeight: 1.22, + textAlign: "left", + }), + ); + } + + if (bodyText) { + drafts.push( + createTextNodeDraft({ + parentId: params.parentNodeId, + x: params.parentPosition.x + safeLeft, + y: params.parentPosition.y + 304, + width: textWidth, + height: 260, + content: bodyText, + fontSize: params.bodyFontSize, + fontWeight: params.bodyFontWeight, + color: "#4b5563", + lineHeight: 1.38, + textAlign: "left", + }), + ); + } + + return drafts; +} diff --git a/lib/detail-ui-validator.ts b/lib/detail-ui-validator.ts new file mode 100644 index 000000000..543ad6543 --- /dev/null +++ b/lib/detail-ui-validator.ts @@ -0,0 +1,149 @@ +import type { + DetailUiProtocol, + DetailUiScreenProtocol, + DetailUiTextBlock, +} from "@/lib/detail-ui-protocol"; + +export type DetailUiValidationIssueType = + | "out_of_safe_area" + | "block_overlap" + | "reading_order" + | "typography_hierarchy"; + +export interface DetailUiValidationIssue { + type: DetailUiValidationIssueType; + message: string; + screenIndex: number; + blockIds?: string[]; +} + +export interface DetailUiValidationResult { + passed: boolean; + issues: DetailUiValidationIssue[]; +} + +function intersects(a: DetailUiTextBlock["frame"], b: DetailUiTextBlock["frame"]) { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +function validateSingleScreen( + screen: DetailUiScreenProtocol, + screenSize: { width: number; height: number }, +): DetailUiValidationIssue[] { + const issues: DetailUiValidationIssue[] = []; + const blocks = [...screen.blocks]; + + for (const block of blocks) { + const safe = screen.safeArea; + const blockRight = block.frame.x + block.frame.width; + const blockBottom = block.frame.y + block.frame.height; + + if ( + block.frame.x < safe.left || + blockRight > screenSize.width - safe.right || + block.frame.y < safe.top || + blockBottom > safe.bottom + ) { + issues.push({ + type: "out_of_safe_area", + screenIndex: screen.index, + blockIds: [block.id], + message: `屏${screen.index} 的 ${block.type} 超出安全区,需回到可读区域内。`, + }); + } + } + + for (let i = 0; i < blocks.length; i += 1) { + for (let j = i + 1; j < blocks.length; j += 1) { + if (intersects(blocks[i]!.frame, blocks[j]!.frame)) { + issues.push({ + type: "block_overlap", + screenIndex: screen.index, + blockIds: [blocks[i]!.id, blocks[j]!.id], + message: `屏${screen.index} 存在文字块重叠:${blocks[i]!.type} 与 ${blocks[j]!.type}。`, + }); + } + } + } + + const title = blocks.find((block) => block.type === "title"); + const subtitle = blocks.find((block) => block.type === "subtitle"); + const body = blocks.find((block) => block.type === "body"); + + if (title && subtitle && title.frame.y > subtitle.frame.y) { + issues.push({ + type: "reading_order", + screenIndex: screen.index, + blockIds: [title.id, subtitle.id], + message: `屏${screen.index} 的标题和副标题顺序异常,标题必须在副标题上方。`, + }); + } + + if (subtitle && body && subtitle.frame.y > body.frame.y) { + issues.push({ + type: "reading_order", + screenIndex: screen.index, + blockIds: [subtitle.id, body.id], + message: `屏${screen.index} 的副标题和正文顺序异常,副标题必须在正文上方。`, + }); + } + + if ( + title && + subtitle && + !(title.style.fontSize > subtitle.style.fontSize) + ) { + issues.push({ + type: "typography_hierarchy", + screenIndex: screen.index, + blockIds: [title.id, subtitle.id], + message: `屏${screen.index} 的标题字号层级不足,标题应明显大于副标题。`, + }); + } + + if ( + subtitle && + body && + !(subtitle.style.fontSize > body.style.fontSize) + ) { + issues.push({ + type: "typography_hierarchy", + screenIndex: screen.index, + blockIds: [subtitle.id, body.id], + message: `屏${screen.index} 的副标题字号层级不足,副标题应明显大于正文。`, + }); + } + + return issues; +} + +export function validateDetailUiProtocol( + protocol?: DetailUiProtocol | null, +): DetailUiValidationResult { + if (!protocol) { + return { + passed: false, + issues: [ + { + type: "reading_order", + screenIndex: 0, + message: "缺少详情 UI 协议,无法校验排版。", + }, + ], + }; + } + + const issues = protocol.screens.flatMap((screen) => + validateSingleScreen(screen, protocol.screenSize), + ); + + return { + passed: issues.length === 0, + issues, + }; +}