feat: accept kimi direct detail dsl output
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-22 12:42:33 +08:00
parent 05cf3ae54e
commit 9276e6c35c
4 changed files with 304 additions and 4 deletions

View File

@ -19,6 +19,10 @@ import {
type DetailUiProtocol, type DetailUiProtocol,
} from "@/lib/detail-ui-protocol"; } from "@/lib/detail-ui-protocol";
import { buildDetailUiSurfaceSnapshot } from "@/lib/detail-ui-surface-adapter"; import { buildDetailUiSurfaceSnapshot } from "@/lib/detail-ui-surface-adapter";
import {
normalizeDetailUiDslV2Document,
type DetailUiDslV2Document,
} from "@/lib/detail-ui-dsl-v2";
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator"; import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol"; import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
@ -241,6 +245,7 @@ type DetailPlanResponse = {
screens: DetailPlanScreen[]; screens: DetailPlanScreen[];
uiProtocol?: DetailUiProtocol; uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot; uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
model?: { model?: {
id: string; id: string;
displayName: string; displayName: string;
@ -2008,8 +2013,11 @@ export async function POST(request: Request) {
(hasBundleImages (hasBundleImages
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` ? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + : `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
(generationMode === "agent_layout"
? `15) 当前为 AGENT 详情模式:除 screens 外,还必须返回 dslV2Screens。每个 dslV2Screens[i] 都是可直接渲染的 detail-ui-dsl/v2 文档,必须包含 version、screenIndex、layoutVariant、region、nodes。nodes 仅允许这些 kind:panel、stack、row、badge、heading、body、feature-card、spec-table、compare、proof、cta。\n`
: "") +
`输出格式:\n` + `输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}],"dslV2Screens":[{"version":"detail-ui-dsl/v2","screenIndex":1,"layoutVariant":"hero-left-panel","region":{"x":0.06,"y":0.05,"width":0.88,"height":0.46},"nodes":[{"kind":"panel","id":"hero-panel","variant":"glass","tone":"light","direction":"column","gap":18,"children":[{"kind":"badge","id":"screen-badge","icon":"sparkle","text":"核心升级","tone":"primary"},{"kind":"heading","id":"hero-title","text":"护腰椅,久坐不腰酸","level":1,"emphasis":"hero"},{"kind":"body","id":"hero-subtitle","text":"在家里加班,也能护腰/舒适","emphasis":"strong"}]}]}]}`,
temperature: 0.25, temperature: 0.25,
...(localDetailAgentResolved ? { temperature: 1 } : {}), ...(localDetailAgentResolved ? { temperature: 1 } : {}),
maxTokens: 1800, maxTokens: 1800,
@ -2088,7 +2096,12 @@ export async function POST(request: Request) {
throw error; throw error;
} }
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || ""); const parsed = extractJsonBlock<{
summary?: unknown;
globalStyle?: unknown;
screens?: unknown;
dslV2Screens?: unknown;
}>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) => const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
hasModelImage hasModelImage
@ -2202,6 +2215,21 @@ export async function POST(request: Request) {
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`, : `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
language language
); );
const uiDslV2Screens =
!shouldFallback && !preflight.rewriteApplied && Array.isArray(parsed?.dslV2Screens)
? parsed.dslV2Screens
.map((item) => normalizeDetailUiDslV2Document(item))
.filter(Boolean)
.map((item) => ({
...item!,
surfaceId: `detail-dsl-${generationMode}-${item!.screenIndex}`,
generationMode,
screenSize: {
width: 790,
height: 1300,
},
}))
: undefined;
const response: DetailPlanResponse = { const response: DetailPlanResponse = {
summary, summary,
@ -2253,6 +2281,7 @@ export async function POST(request: Request) {
}, },
}) })
: undefined, : undefined,
uiDslV2Screens,
model: { model: {
id: selectedModelRef.id, id: selectedModelRef.id,
displayName: selectedModelRef.displayName, displayName: selectedModelRef.displayName,

View File

@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol"; import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol"; import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { import {
buildDetailTextLayerNodes, buildDetailTextLayerNodes,
buildDetailTextLayerNodesFromReviewPatch, buildDetailTextLayerNodesFromReviewPatch,
@ -1265,6 +1266,7 @@ type DetailPlanResponse = {
}; };
uiProtocol?: DetailUiProtocol; uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot; uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
}; };
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number]; type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
@ -6777,6 +6779,10 @@ export function CanvasChatPanel({
surface: currentDetailSurfaceSnapshot, surface: currentDetailSurfaceSnapshot,
screenIndex: index + 1, screenIndex: index + 1,
}), }),
detailUiDslDocument:
Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index]
? planData.uiDslV2Screens[index]
: null,
detailSubjectAnalysis, detailSubjectAnalysis,
detailGeneratingPlaceholder: false, detailGeneratingPlaceholder: false,
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`, detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,

View File

@ -40,6 +40,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import type { PickedObject } from "@/store/canvas"; import type { PickedObject } from "@/store/canvas";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder"; import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { import {
normalizeDetailUiSubjectAnalysis, normalizeDetailUiSubjectAnalysis,
type DetailUiSubjectAnalysis, type DetailUiSubjectAnalysis,
@ -383,13 +384,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const detailSubjectAnalysis = useMemo(() => { const detailSubjectAnalysis = useMemo(() => {
return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis); return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis);
}, [data.metadata]); }, [data.metadata]);
const detailUiDslDocumentFromMetadata = useMemo(() => {
const raw = data.metadata?.detailUiDslDocument;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiDslV2Document;
}, [data.metadata]);
const detailUiDslDocument = useMemo(() => { const detailUiDslDocument = useMemo(() => {
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
if (!detailUiScreenOverlay) return null; if (!detailUiScreenOverlay) return null;
return buildDetailUiDslV2({ return buildDetailUiDslV2({
overlay: detailUiScreenOverlay, overlay: detailUiScreenOverlay,
subjectAnalysis: detailSubjectAnalysis, subjectAnalysis: detailSubjectAnalysis,
}); });
}, [detailSubjectAnalysis, detailUiScreenOverlay]); }, [detailSubjectAnalysis, detailUiDslDocumentFromMetadata, detailUiScreenOverlay]);
const shouldRenderDetailOverlay = const shouldRenderDetailOverlay =
data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay); data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay);
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId const shortAgentExecutionSnapshotId = agentExecutionSnapshotId

View File

@ -74,7 +74,7 @@ export type DetailUiDslNode =
| { | {
kind: "proof"; kind: "proof";
id: string; id: string;
icon?: "shield" | "check" | "sparkle"; icon?: "shield" | "check" | "sparkle" | "arrow";
text: string; text: string;
} }
| { | {
@ -104,3 +104,261 @@ export interface DetailUiDslV2Document {
| "cta-focus"; | "cta-focus";
nodes: DetailUiDslNode[]; nodes: DetailUiDslNode[];
} }
function clamp01(value: unknown, fallback: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.min(1, parsed));
}
function normalizeString(value: unknown, fallback = "") {
return String(value || "").replace(/\s+/g, " ").trim() || fallback;
}
function normalizeRegion(value: unknown): DetailUiDslRegion | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const region = {
x: clamp01(raw.x, 0),
y: clamp01(raw.y, 0),
width: clamp01(raw.width, 0),
height: clamp01(raw.height, 0),
} satisfies DetailUiDslRegion;
if (region.width <= 0 || region.height <= 0) return null;
return region;
}
function normalizeNode(value: unknown): DetailUiDslNode | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const kind = normalizeString(raw.kind);
const id = normalizeString(raw.id);
if (!kind || !id) return null;
if (kind === "panel") {
const children = Array.isArray(raw.children)
? raw.children.map(normalizeNode).filter(Boolean) as DetailUiDslNode[]
: [];
if (children.length === 0) return null;
return {
kind: "panel",
id,
variant:
raw.variant === "glass" || raw.variant === "solid" || raw.variant === "ghost"
? raw.variant
: undefined,
tone:
raw.tone === "light" || raw.tone === "dark" || raw.tone === "accent"
? raw.tone
: undefined,
region: normalizeRegion(raw.region) || undefined,
direction: raw.direction === "row" ? "row" : "column",
gap: Number.isFinite(Number(raw.gap)) ? Math.max(0, Number(raw.gap)) : undefined,
children,
};
}
if (kind === "stack") {
const children = Array.isArray(raw.children)
? raw.children.map(normalizeNode).filter(Boolean) as DetailUiDslNode[]
: [];
if (children.length === 0) return null;
return {
kind: "stack",
id,
gap: Number.isFinite(Number(raw.gap)) ? Math.max(0, Number(raw.gap)) : undefined,
children,
};
}
if (kind === "row") {
const children = Array.isArray(raw.children)
? raw.children.map(normalizeNode).filter(Boolean) as DetailUiDslNode[]
: [];
if (children.length === 0) return null;
return {
kind: "row",
id,
gap: Number.isFinite(Number(raw.gap)) ? Math.max(0, Number(raw.gap)) : undefined,
columns: Number.isFinite(Number(raw.columns)) ? Math.max(1, Number(raw.columns)) : undefined,
children,
};
}
if (kind === "badge") {
const text = normalizeString(raw.text);
if (!text) return null;
return {
kind: "badge",
id,
icon:
raw.icon === "sparkle" || raw.icon === "shield" || raw.icon === "arrow" || raw.icon === "check"
? raw.icon
: undefined,
text,
tone:
raw.tone === "primary" || raw.tone === "secondary" || raw.tone === "neutral"
? raw.tone
: undefined,
};
}
if (kind === "heading") {
const text = normalizeString(raw.text);
if (!text) return null;
return {
kind: "heading",
id,
text,
level: raw.level === 2 || raw.level === 3 ? raw.level : 1,
emphasis: raw.emphasis === "section" ? "section" : "hero",
};
}
if (kind === "body") {
const text = normalizeString(raw.text);
if (!text) return null;
return {
kind: "body",
id,
text,
emphasis:
raw.emphasis === "muted" || raw.emphasis === "strong"
? raw.emphasis
: "normal",
};
}
if (kind === "feature-card") {
const title = normalizeString(raw.title);
if (!title) return null;
return {
kind: "feature-card",
id,
icon:
raw.icon === "sparkle" || raw.icon === "shield" || raw.icon === "arrow" || raw.icon === "check"
? raw.icon
: undefined,
tone:
raw.tone === "primary" || raw.tone === "secondary" || raw.tone === "neutral"
? raw.tone
: undefined,
tag: normalizeString(raw.tag) || undefined,
title,
body: normalizeString(raw.body) || undefined,
};
}
if (kind === "spec-table") {
const rows = Array.isArray(raw.rows)
? raw.rows
.map((row) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
const rowRaw = row as Record<string, unknown>;
const label = normalizeString(rowRaw.label);
const valueText = normalizeString(rowRaw.value);
if (!label || !valueText) return null;
return { label, value: valueText };
})
.filter(Boolean) as Array<{ label: string; value: string }>
: [];
if (rows.length === 0) return null;
return { kind: "spec-table", id, rows };
}
if (kind === "compare") {
const beforeRaw =
raw.before && typeof raw.before === "object" && !Array.isArray(raw.before)
? (raw.before as Record<string, unknown>)
: null;
const afterRaw =
raw.after && typeof raw.after === "object" && !Array.isArray(raw.after)
? (raw.after as Record<string, unknown>)
: null;
if (!beforeRaw || !afterRaw) return null;
const beforeTitle = normalizeString(beforeRaw.title);
const beforeBody = normalizeString(beforeRaw.body);
const afterTitle = normalizeString(afterRaw.title);
const afterBody = normalizeString(afterRaw.body);
if (!beforeTitle || !beforeBody || !afterTitle || !afterBody) return null;
return {
kind: "compare",
id,
before: { title: beforeTitle, body: beforeBody },
after: { title: afterTitle, body: afterBody },
};
}
if (kind === "proof") {
const text = normalizeString(raw.text);
if (!text) return null;
return {
kind: "proof",
id,
icon:
raw.icon === "sparkle" || raw.icon === "shield" || raw.icon === "arrow" || raw.icon === "check"
? raw.icon
: undefined,
text,
};
}
if (kind === "cta") {
const buttonText = normalizeString(raw.buttonText);
if (!buttonText) return null;
return {
kind: "cta",
id,
headline: normalizeString(raw.headline) || undefined,
buttonText,
supportText: normalizeString(raw.supportText) || undefined,
};
}
return null;
}
export function normalizeDetailUiDslV2Document(
value: unknown,
): DetailUiDslV2Document | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const region = normalizeRegion(raw.region);
if (!region) return null;
const nodes = Array.isArray(raw.nodes)
? raw.nodes.map(normalizeNode).filter(Boolean) as DetailUiDslNode[]
: [];
if (nodes.length === 0) return null;
const layoutVariant =
raw.layoutVariant === "hero-right-panel" ||
raw.layoutVariant === "top-banner-bottom-grid" ||
raw.layoutVariant === "floating-split" ||
raw.layoutVariant === "cta-focus"
? raw.layoutVariant
: "hero-left-panel";
const screenSizeRaw =
raw.screenSize && typeof raw.screenSize === "object" && !Array.isArray(raw.screenSize)
? (raw.screenSize as Record<string, unknown>)
: {};
return {
version: "detail-ui-dsl/v2",
surfaceId: normalizeString(raw.surfaceId, "detail-ui-dsl-v2"),
screenIndex: Math.max(1, Number(raw.screenIndex || 1)),
generationMode: raw.generationMode === "classic" ? "classic" : "agent_layout",
screenSize: {
width: Math.max(1, Number(screenSizeRaw.width || 790)),
height: Math.max(1, Number(screenSizeRaw.height || 1300)),
},
designStyle:
raw.designStyle && typeof raw.designStyle === "object" && !Array.isArray(raw.designStyle)
? (raw.designStyle as DetailUiGlobalStyle)
: undefined,
region,
layoutVariant,
nodes,
};
}