ai-chatbot/lib/detail-ui-surface-adapter.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

298 lines
7.8 KiB
TypeScript

import type {
DetailGenerationMode,
DetailUiGlobalStyle,
DetailUiProtocol,
DetailUiScreenInput,
DetailUiScreenProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import type {
UiSurfaceComponent,
UiSurfacePatch,
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
function mapBlockTypeToBindingKey(blockType: "title" | "subtitle" | "body") {
if (blockType === "title") return "title";
if (blockType === "subtitle") return "subtitle";
return "copy";
}
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",
},
};
}
function createScreenSubtree(params: {
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
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,
},
},
];
const blocks = params.screenProtocol?.blocks || [];
blocks.forEach((block, blockIndex) => {
const bindingKey = mapBlockTypeToBindingKey(block.type);
components.push({
id: `${screenId}-text-${blockIndex + 1}`,
type: "detail_text_block",
parentId: textStackId,
props: {
blockType: block.type,
frame: block.frame,
textKey: bindingKey,
},
bindings: {
content: {
kind: "text",
path: `/screens/${params.screenIndex - 1}/${bindingKey}`,
},
},
style: {
fontFamily: block.style.fontFamily || "",
fontSize: block.style.fontSize,
fontWeight: block.style.fontWeight,
lineHeight: block.style.lineHeight,
color: block.style.color,
textAlign: block.style.textAlign,
},
layout: {
frame: block.frame,
},
metadata: {
screenIndex: params.screenIndex,
blockType: block.type,
blockId: block.id,
},
});
});
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,
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,
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
}).filter((component) => component.id === textStackId || component.parentId === textStackId);
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
);
}