From 9276e6c35c7b072e4a6edd8d2eeb7dab127ffbe0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 12:42:33 +0800 Subject: [PATCH] feat: accept kimi direct detail dsl output --- app/api/canvas/detail-plan/route.ts | 33 +++- components/canvas/CanvasChatPanel.tsx | 6 + components/canvas/nodes/ImageNode.tsx | 9 +- lib/detail-ui-dsl-v2.ts | 260 +++++++++++++++++++++++++- 4 files changed, 304 insertions(+), 4 deletions(-) diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index cfd3df884..3cc6c77d9 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -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, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 55d53b4ab..8d86c7dc9 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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[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}`, diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index b9f7ec11d..b375a32cf 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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 diff --git a/lib/detail-ui-dsl-v2.ts b/lib/detail-ui-dsl-v2.ts index 9c33b37d8..166c1093c 100644 --- a/lib/detail-ui-dsl-v2.ts +++ b/lib/detail-ui-dsl-v2.ts @@ -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; + 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; + 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; + 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) + : null; + const afterRaw = + raw.after && typeof raw.after === "object" && !Array.isArray(raw.after) + ? (raw.after as Record) + : 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; + 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) + : {}; + + 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, + }; +}