feat: add detail ui protocol foundation
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-21 19:30:57 +08:00
parent 8bd15f25c6
commit 3e4594273b
6 changed files with 981 additions and 145 deletions

View File

@ -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,10 +2139,17 @@ 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,
@ -2106,6 +2159,7 @@ export async function POST(request: Request) {
rewriteApplied: preflight.rewriteApplied,
},
screens,
uiProtocol,
model: {
id: plannerConfig.model.id,
displayName: plannerConfig.model.displayName,

View File

@ -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,

View File

@ -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<DetailPlanResponse["screens"]>[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<Omit<CanvasNode, "id" | "createdAt" | "updatedAt">>;
}) => {
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();
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,
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,
},
connections: [],
parentId: params.nodeId,
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 (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,
replaceDetailTextLayers({
nodeId: params.nodeId,
drafts,
});
}
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];
@ -6306,15 +6304,19 @@ export function CanvasChatPanel({
let finalGenerated = firstPassImage;
let finalGenerationPrompt = generationPrompt;
let screenReviewResult: DetailScreenReviewResult | null = null;
if (!isAgentLayoutMode) {
const targetNodeId = detailPlaceholderNodeIds[index];
patchDetailPlaceholder(index, {
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
label: isAgentLayoutMode
? `协议质检中 ${index + 1}/${prompts.length}`
: `双总监审稿中 ${index + 1}/${prompts.length}`,
status: "generating",
});
setDetailProgress({
status: "running",
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
stage: isAgentLayoutMode
? `第 ${index + 1}/${prompts.length} 屏协议质检中,Agent 正在检查背景与文字层协议...`
: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
current: reviewStepCurrent,
total: totalSteps,
});
@ -6326,7 +6328,9 @@ export function CanvasChatPanel({
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
text: isAgentLayoutMode
? `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在检查背景画面与协议文字层...`
: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
},
],
}
@ -6340,6 +6344,7 @@ export function CanvasChatPanel({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outputLanguage,
generationMode: activeDetailGenerationMode,
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
referenceImages: generationReferenceImagesBase.slice(0, 6),
brief,
@ -6363,12 +6368,68 @@ export function CanvasChatPanel({
console.error("[CanvasChat] Detail screen review failed:", reviewError);
}
const shouldApplyProtocolPatch = Boolean(
screenReviewResult?.uiPatch &&
screenReviewResult?.reviewAction === "protocol_patch",
);
const shouldRegenerate =
screenReviewResult?.pass === false &&
!shouldApplyProtocolPatch &&
screenReviewResult?.reviewAction === "regenerate_image" &&
typeof screenReviewResult?.revisedPrompt === "string" &&
screenReviewResult.revisedPrompt.trim().length > 20;
if (shouldRegenerate) {
if (shouldApplyProtocolPatch) {
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 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,
});
if (replacementDrafts.length > 0) {
replaceDetailTextLayers({
nodeId: targetNodeId,
drafts: replacementDrafts,
});
}
}
} else if (shouldRegenerate) {
patchDetailPlaceholder(index, {
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
status: "generating",
@ -6409,8 +6470,7 @@ export function CanvasChatPanel({
}
}
}
}
} 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],
});
}
}

301
lib/detail-ui-protocol.ts Normal file
View File

@ -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 })),
],
};
}

175
lib/detail-ui-renderer.ts Normal file
View File

@ -0,0 +1,175 @@
import type {
DetailUiProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import type { CanvasNode, TextNodeData } from "@/types/canvas";
type CanvasTextNodeDraft = Omit<CanvasNode, "id" | "createdAt" | "updatedAt">;
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<string, unknown>;
}): 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<string, unknown> },
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;
}

149
lib/detail-ui-validator.ts Normal file
View File

@ -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,
};
}