ai-chatbot/lib/detail-ui-protocol.ts
Your Name 10e1096c83
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add detail surface protocol workflow
2026-03-21 20:55:04 +08:00

394 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}
function applyReviewHeuristicsToScreen(params: {
screen: DetailUiScreenProtocol;
screenSize: { width: number; height: number };
notes: DetailUiPatchNote[];
}) {
const noteText = params.notes.map((note) => note.message).join(" | ").toLowerCase();
if (!noteText.trim()) {
return params.screen;
}
const emphasizeTitle =
/标题层级|headline|title hierarchy|主标题|标题不够突出|主副标题/.test(noteText);
const addBreathingRoom =
/留白|spacing|rhythm|拥挤|挤|crowd|breathing|间距/.test(noteText);
const protectHeadlineZone =
/顶部|headline zone|safe area|安全区|避免重叠|不要遮挡|hero/.test(noteText);
const softenBodyDensity =
/正文|文案过密|copy too dense|copy density|太多文字|可读性/.test(noteText);
const safeArea = {
...params.screen.safeArea,
top: protectHeadlineZone
? Math.min(params.screen.safeArea.top + 12, params.screen.safeArea.bottom - 120)
: params.screen.safeArea.top,
};
const gapTitleToSubtitle = addBreathingRoom ? 24 : 16;
const gapSubtitleToBody = addBreathingRoom ? 20 : 14;
let cursorY = safeArea.top;
const blocks = params.screen.blocks.map((block) => {
const nextBlock = {
...block,
frame: { ...block.frame },
style: { ...block.style },
};
if (nextBlock.type === "title" && emphasizeTitle) {
nextBlock.style.fontSize = Math.min(84, nextBlock.style.fontSize + 4);
nextBlock.style.lineHeight = 1.12;
}
if (nextBlock.type === "subtitle" && emphasizeTitle) {
nextBlock.style.fontSize = Math.max(22, nextBlock.style.fontSize - 1);
}
if (nextBlock.type === "body" && softenBodyDensity) {
nextBlock.style.fontSize = Math.max(18, nextBlock.style.fontSize - 2);
nextBlock.style.lineHeight = 1.42;
}
const width = Math.max(
220,
Math.round(params.screenSize.width - safeArea.left - safeArea.right),
);
nextBlock.frame.x = safeArea.left;
nextBlock.frame.width = width;
nextBlock.frame.y = cursorY;
nextBlock.frame.height = estimateBlockHeight(
nextBlock.content,
nextBlock.style,
nextBlock.frame.width,
nextBlock.type === "title" ? 2 : nextBlock.type === "subtitle" ? 3 : 5,
);
if (nextBlock.type === "body") {
const maxBodyBottom = Math.max(cursorY + 80, safeArea.bottom);
nextBlock.frame.height = Math.min(nextBlock.frame.height, maxBodyBottom - cursorY);
}
cursorY +=
nextBlock.frame.height +
(nextBlock.type === "title"
? gapTitleToSubtitle
: nextBlock.type === "subtitle"
? gapSubtitleToBody
: 0);
return nextBlock;
});
return {
...params.screen,
safeArea,
blocks,
} satisfies DetailUiScreenProtocol;
}
export function buildDetailUiReviewPatch(params: {
generationMode: DetailGenerationMode;
screenSize: { width: number; height: number };
globalStyle?: DetailUiGlobalStyle;
screen: DetailUiScreenInput;
layoutFixes?: string[];
typographyFixes?: string[];
conversionFixes?: string[];
}): DetailUiScreenReviewPatch {
const 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 })),
];
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: applyReviewHeuristicsToScreen({
screen: protocol.screens[0]!,
screenSize: params.screenSize,
notes,
}),
notes,
};
}