ai-chatbot/lib/detail-ui-surface-adapter.ts
Your Name a1b6496976
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: upgrade detail section component renderer
2026-03-21 23:35:48 +08:00

689 lines
19 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.

import type {
DetailGenerationMode,
DetailUiGlobalStyle,
DetailUiProtocol,
DetailUiScreenInput,
DetailUiScreenProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import type {
UiSurfaceComponent,
UiSurfacePatch,
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
screenCount: number;
}) {
return `detail-surface-${params.generationMode}-${params.screenCount}-790x1300`;
}
function createRootComponent(): UiSurfaceComponent {
return {
id: "detail-page-root",
type: "detail_page_root",
parentId: null,
props: {
role: "detail_page",
},
};
}
type DetailSurfaceLayoutVariant = "hero_left" | "hero_right" | "split_bottom";
function splitBodyCopyIntoHighlights(copy?: string) {
const segments = String(copy || "")
.split(/[。\n!!??;;]+/g)
.map((segment) => segment.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (segments.length === 0) return [];
if (segments.length <= 3) return segments;
return [
segments.slice(0, 2).join(" · "),
segments.slice(2, 4).join(" · "),
segments.slice(4).join(" · "),
].filter(Boolean);
}
function pickScreenLayoutVariant(params: {
screenIndex: number;
screenType?: string;
}): DetailSurfaceLayoutVariant {
if (params.screenIndex === 1 || params.screenType === "B") {
return "hero_left";
}
if (params.screenType === "A") {
return "hero_right";
}
return "split_bottom";
}
function buildScreenBadgeLabel(params: {
screenIndex: number;
screenType?: string;
objective?: string;
}) {
const normalizedObjective = String(params.objective || "").replace(/\s+/g, " ").trim();
if (normalizedObjective) return normalizedObjective.slice(0, 18);
if (params.screenType === "A") return "技术证明";
if (params.screenType === "B") return "场景共鸣";
if (params.screenType === "C") return "细节证明";
return `第 ${params.screenIndex} 屏`;
}
function createStyledSurfaceTextComponent(params: {
id: string;
type: "detail_card" | "detail_badge" | "detail_text_block";
parentId: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
content?: string;
bindingPath?: string;
role: string;
style: Record<string, unknown>;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: params.type,
parentId: params.parentId,
props: {
role: params.role,
content: params.content || "",
},
bindings: params.bindingPath
? {
content: {
kind: "text" as const,
path: params.bindingPath,
},
}
: undefined,
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
style: params.style,
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function createSectionComponent(params: {
id: string;
parentId: string;
role: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: "detail_section" as const,
parentId: params.parentId,
props: {
role: params.role,
},
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function buildSectionedTextComponents(params: {
screenId: string;
textStackId: string;
screenIndex: number;
screenSize: { width: number; height: number };
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenProtocol?: DetailUiScreenProtocol;
}) {
const safeArea = params.screenProtocol?.safeArea || {
top: 64,
bottom: 360,
left: 56,
right: 56,
};
const contentWidth = Math.max(
260,
Math.round(params.screenSize.width - safeArea.left - safeArea.right),
);
const variant = pickScreenLayoutVariant({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
});
const badgeLabel = buildScreenBadgeLabel({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
objective: params.screen.objective,
});
const bodyHighlights = splitBodyCopyIntoHighlights(params.screen.copy);
const titleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "title") || null;
const subtitleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "subtitle") || null;
const bodyBlock =
params.screenProtocol?.blocks.find((block) => block.type === "body") || null;
const cardX =
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(430, contentWidth))
: safeArea.left;
const cardWidth = Math.min(430, contentWidth);
const titleY = safeArea.top + 42;
const subtitleY = titleY + Math.max(124, Math.round(Number(titleBlock?.frame.height || 132))) + 14;
const components: UiSurfaceComponent[] = [];
components.push(
createSectionComponent({
id: `${params.screenId}-section-hero`,
parentId: params.textStackId,
role: "hero_section",
frame: {
x: cardX,
y: safeArea.top,
width: cardWidth,
height: Math.min(360, params.screenSize.height - safeArea.top - 120),
},
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-badge`,
type: "detail_badge",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: safeArea.top,
width: Math.min(156, Math.max(104, badgeLabel.length * 18)),
height: 34,
},
zIndex: 3,
content: badgeLabel,
role: "screen_badge",
style: {
fontSize: 16,
fontWeight: "700",
color: "#f8fafc",
textAlign: "center",
lineHeight: 1.15,
letterSpacing: 0.4,
backgroundColor: "rgba(15,23,42,0.82)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 999,
padding: 8,
boxShadow: "0 10px 28px rgba(15,23,42,0.20)",
backdropBlur: 8,
textTransform: "uppercase",
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "badge",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-title-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: titleY,
width: cardWidth,
height: Math.max(132, Math.round(Number(titleBlock?.frame.height || 132) + 34)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/title`,
role: "title_card",
style: {
fontFamily: titleBlock?.style.fontFamily || undefined,
fontSize: Math.max(34, Number(titleBlock?.style.fontSize || 52)),
fontWeight: titleBlock?.style.fontWeight || "800",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.08,
letterSpacing: -0.8,
backgroundColor: "rgba(255,255,255,0.86)",
borderColor: "rgba(255,255,255,0.38)",
borderWidth: 1,
borderRadius: 28,
padding: 24,
boxShadow: "0 22px 58px rgba(15,23,42,0.18)",
backdropBlur: 14,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "title",
},
}),
);
if (params.screen.subtitle) {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-subtitle-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: subtitleY,
width: Math.min(cardWidth, 360),
height: Math.max(72, Math.round(Number(subtitleBlock?.frame.height || 64) + 26)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/subtitle`,
role: "subtitle_card",
style: {
fontFamily: subtitleBlock?.style.fontFamily || undefined,
fontSize: Math.max(22, Number(subtitleBlock?.style.fontSize || 28)),
fontWeight: subtitleBlock?.style.fontWeight || "700",
color: "#f8fafc",
textAlign: "left",
lineHeight: 1.18,
backgroundColor:
variant === "hero_right" ? "rgba(37,99,235,0.82)" : "rgba(30,41,59,0.78)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 22,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.18)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "subtitle",
},
}),
);
}
const supportingSectionY =
variant === "split_bottom" ? Math.round(params.screenSize.height * 0.72) : subtitleY + 96;
const supportingSectionHeight =
variant === "split_bottom"
? Math.min(320, params.screenSize.height - supportingSectionY - 80)
: 240;
components.push(
createSectionComponent({
id: `${params.screenId}-section-supporting`,
parentId: params.textStackId,
role: variant === "split_bottom" ? "supporting_bottom_section" : "supporting_grid",
frame: {
x: safeArea.left,
y: supportingSectionY,
width: contentWidth,
height: supportingSectionHeight,
},
zIndex: 1,
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
const supportingCards =
bodyHighlights.length > 0
? bodyHighlights.slice(0, 3)
: [String(params.screen.copy || "").trim()].filter(Boolean);
const cardGap = 14;
const supportColumns = variant === "split_bottom" ? Math.min(3, supportingCards.length) : Math.min(2, Math.max(1, supportingCards.length));
const supportCardWidth = Math.floor(
(contentWidth - cardGap * Math.max(0, supportColumns - 1)) / supportColumns,
);
supportingCards.forEach((highlight, index) => {
const column = supportColumns === 0 ? 0 : index % supportColumns;
const row = supportColumns === 0 ? 0 : Math.floor(index / supportColumns);
const x = safeArea.left + column * (supportCardWidth + cardGap);
const y = supportingSectionY + row * 116;
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-support-card-${index + 1}`,
type: "detail_card",
parentId: `${params.screenId}-section-supporting`,
frame: {
x,
y,
width: supportCardWidth,
height: variant === "split_bottom" ? 102 : 96,
},
zIndex: 2,
content: highlight,
role: "support_card",
style: {
fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || 22)) : 20,
fontWeight: "600",
color: "#111827",
textAlign: "left",
lineHeight: 1.32,
backgroundColor:
variant === "split_bottom"
? "rgba(255,255,255,0.92)"
: "rgba(248,250,252,0.82)",
borderColor:
index === 0 ? "rgba(59,130,246,0.42)" : "rgba(148,163,184,0.28)",
borderWidth: 1,
borderRadius: 24,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "support_card",
},
}),
);
});
if (params.screen.layoutNote) {
const noteY = Math.min(
params.screenSize.height - 116,
supportingSectionY + supportingSectionHeight - 74,
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-proof-card`,
type: "detail_card",
parentId: `${params.screenId}-section-supporting`,
frame: {
x:
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(260, contentWidth * 0.46))
: safeArea.left,
y: noteY,
width: Math.min(260, contentWidth * 0.46),
height: 62,
},
zIndex: 2,
content: String(params.screen.layoutNote || "").replace(/\s+/g, " ").trim(),
role: "proof_chip",
style: {
fontSize: 16,
fontWeight: "600",
color: "#1e293b",
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(15,23,42,0.10)",
borderWidth: 1,
borderRadius: 18,
padding: 14,
boxShadow: "0 14px 36px rgba(15,23,42,0.12)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "proof_chip",
},
}),
);
}
return components;
}
function createScreenSubtree(params: {
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenSize: { width: number; height: number };
screenProtocol?: DetailUiScreenProtocol;
rootId?: string;
}) {
const screenId = params.rootId || `detail-screen-${params.screenIndex}`;
const backgroundId = `${screenId}-background`;
const textStackId = `${screenId}-text-stack`;
const components: UiSurfaceComponent[] = [
{
id: screenId,
type: "detail_screen",
parentId: "detail-page-root",
props: {
screenIndex: params.screenIndex,
screenType: params.screen.screenType || "",
objective: params.screen.objective || "",
layoutNote: params.screen.layoutNote || "",
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: backgroundId,
type: "detail_background_image",
parentId: screenId,
slot: "background",
props: {
screenIndex: params.screenIndex,
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: textStackId,
type: "detail_text_stack",
parentId: screenId,
slot: "content",
props: {
screenIndex: params.screenIndex,
safeArea: params.screenProtocol?.safeArea || null,
},
metadata: {
screenIndex: params.screenIndex,
},
},
];
components.push(
...buildSectionedTextComponents({
screenId,
textStackId,
screenIndex: params.screenIndex,
screenSize: params.screenSize,
screen: params.screen,
screenProtocol: params.screenProtocol,
}),
);
return components;
}
function buildDetailSurfaceDataModel(params: {
summary?: string;
screens: DetailUiScreenInput[];
globalStyle?: DetailUiGlobalStyle;
}) {
return {
summary: params.summary || "",
globalStyle: params.globalStyle || {},
screens: params.screens.map((screen) => ({
index: screen.index,
title: String(screen.title || "").trim(),
subtitle: String(screen.subtitle || "").trim(),
copy: String(screen.copy || "").trim(),
screenType: screen.screenType || "",
objective: screen.objective || "",
layoutNote: screen.layoutNote || "",
})),
};
}
export function buildDetailUiSurfaceSnapshot(params: {
surfaceId?: string;
generationMode: DetailGenerationMode;
screenSize: { width: number; height: number };
summary?: string;
globalStyle?: DetailUiGlobalStyle;
screens: DetailUiScreenInput[];
uiProtocol?: DetailUiProtocol;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
const surfaceId =
params.surfaceId ||
createDetailSurfaceId({
generationMode: params.generationMode,
screenCount: params.screens.length,
});
const components: UiSurfaceComponent[] = [createRootComponent()];
params.screens.forEach((screen) => {
const screenProtocol = params.uiProtocol?.screens.find(
(item) => item.index === screen.index,
);
components.push(
...createScreenSubtree({
screenIndex: screen.index,
screen,
screenSize: params.screenSize,
screenProtocol,
}),
);
});
return {
version: "ui-surface/v1",
surfaceId,
surfaceType: "detail_page",
generationMode: params.generationMode,
rootComponentId: "detail-page-root",
screenSize: params.screenSize,
components,
dataModel: buildDetailSurfaceDataModel({
summary: params.summary,
screens: params.screens,
globalStyle: params.globalStyle,
}),
validation: params.uiValidation
? {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "plan",
}
: undefined,
metadata: {
detailScreenCount: params.screens.length,
},
} satisfies UiSurfaceSnapshot;
}
export function buildDetailUiSurfacePatchFromReview(params: {
surfaceId: string;
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"index" | "title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
uiPatch?: DetailUiScreenReviewPatch;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
if (!params.uiPatch) return null;
const screenId = `detail-screen-${params.screenIndex}`;
const textStackId = `${screenId}-text-stack`;
const replacementComponents = createScreenSubtree({
screenIndex: params.screenIndex,
screen: params.screen,
screenSize: {
width: 790,
height: 1300,
},
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
}).filter(
(component) =>
component.id !== screenId &&
component.id !== `${screenId}-background`,
);
return {
version: "ui-surface-patch/v1",
surfaceId: params.surfaceId,
surfaceType: "detail_page",
operations: [
{
type: "replace_component_subtree",
componentId: textStackId,
components: replacementComponents,
},
{
type: "set_data",
updates: [
{
path: `/screens/${params.screenIndex - 1}/title`,
value: String(params.screen.title || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/subtitle`,
value: String(params.screen.subtitle || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/copy`,
value: String(params.screen.copy || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/layoutNote`,
value: String(params.screen.layoutNote || "").trim(),
},
],
},
...(params.uiValidation
? [
{
type: "set_validation" as const,
validation: {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "review" as const,
},
},
]
: []),
],
} satisfies UiSurfacePatch;
}
export function findDetailSurfaceScreenComponent(
snapshot: UiSurfaceSnapshot | null | undefined,
screenIndex: number,
) {
if (!snapshot) return null;
return (
snapshot.components.find(
(component) =>
component.type === "detail_screen" &&
Number(component.props?.screenIndex || 0) === screenIndex,
) || null
);
}