ai-chatbot/lib/detail-ui-protocol.ts
Your Name 3e4594273b
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add detail ui protocol foundation
2026-03-21 19:30:57 +08:00

302 lines
8.3 KiB
TypeScript
Raw 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,
};
}
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 })),
],
};
}