feat: accept kimi direct detail dsl output
This commit is contained in:
parent
05cf3ae54e
commit
9276e6c35c
@ -19,6 +19,10 @@ import {
|
||||
type DetailUiProtocol,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
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 type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
|
||||
|
||||
@ -241,6 +245,7 @@ type DetailPlanResponse = {
|
||||
screens: DetailPlanScreen[];
|
||||
uiProtocol?: DetailUiProtocol;
|
||||
uiSurface?: UiSurfaceSnapshot;
|
||||
uiDslV2Screens?: DetailUiDslV2Document[];
|
||||
model?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@ -2008,8 +2013,11 @@ export async function POST(request: Request) {
|
||||
(hasBundleImages
|
||||
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 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` +
|
||||
`{"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,
|
||||
...(localDetailAgentResolved ? { temperature: 1 } : {}),
|
||||
maxTokens: 1800,
|
||||
@ -2088,7 +2096,12 @@ export async function POST(request: Request) {
|
||||
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 rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
|
||||
hasModelImage
|
||||
@ -2202,6 +2215,21 @@ export async function POST(request: Request) {
|
||||
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
|
||||
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 = {
|
||||
summary,
|
||||
@ -2253,6 +2281,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
uiDslV2Screens,
|
||||
model: {
|
||||
id: selectedModelRef.id,
|
||||
displayName: selectedModelRef.displayName,
|
||||
|
||||
@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
|
||||
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
|
||||
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
||||
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
|
||||
import {
|
||||
buildDetailTextLayerNodes,
|
||||
buildDetailTextLayerNodesFromReviewPatch,
|
||||
@ -1265,6 +1266,7 @@ type DetailPlanResponse = {
|
||||
};
|
||||
uiProtocol?: DetailUiProtocol;
|
||||
uiSurface?: UiSurfaceSnapshot;
|
||||
uiDslV2Screens?: DetailUiDslV2Document[];
|
||||
};
|
||||
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
|
||||
|
||||
@ -6777,6 +6779,10 @@ export function CanvasChatPanel({
|
||||
surface: currentDetailSurfaceSnapshot,
|
||||
screenIndex: index + 1,
|
||||
}),
|
||||
detailUiDslDocument:
|
||||
Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index]
|
||||
? planData.uiDslV2Screens[index]
|
||||
: null,
|
||||
detailSubjectAnalysis,
|
||||
detailGeneratingPlaceholder: false,
|
||||
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
|
||||
|
||||
@ -40,6 +40,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import type { PickedObject } from "@/store/canvas";
|
||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
|
||||
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
|
||||
import {
|
||||
normalizeDetailUiSubjectAnalysis,
|
||||
type DetailUiSubjectAnalysis,
|
||||
@ -383,13 +384,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
const detailSubjectAnalysis = useMemo(() => {
|
||||
return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis);
|
||||
}, [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(() => {
|
||||
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
|
||||
if (!detailUiScreenOverlay) return null;
|
||||
return buildDetailUiDslV2({
|
||||
overlay: detailUiScreenOverlay,
|
||||
subjectAnalysis: detailSubjectAnalysis,
|
||||
});
|
||||
}, [detailSubjectAnalysis, detailUiScreenOverlay]);
|
||||
}, [detailSubjectAnalysis, detailUiDslDocumentFromMetadata, detailUiScreenOverlay]);
|
||||
const shouldRenderDetailOverlay =
|
||||
data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay);
|
||||
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
|
||||
|
||||
@ -74,7 +74,7 @@ export type DetailUiDslNode =
|
||||
| {
|
||||
kind: "proof";
|
||||
id: string;
|
||||
icon?: "shield" | "check" | "sparkle";
|
||||
icon?: "shield" | "check" | "sparkle" | "arrow";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
@ -104,3 +104,261 @@ export interface DetailUiDslV2Document {
|
||||
| "cta-focus";
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user