635 lines
23 KiB
TypeScript
635 lines
23 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { generateText } from "ai";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
getAgentAiModelWithProvider,
|
||
getEnabledAiModelsWithProvider,
|
||
getSiteSettings,
|
||
} from "@/lib/db/queries";
|
||
import {
|
||
getAiModelAgentCodingModelId,
|
||
isAiModelMultimodal,
|
||
} from "@/lib/db/ai-model-flags";
|
||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
|
||
import { buildDetailKimiGenerateOptions } from "@/lib/ai/detail-agent-kimi-profile";
|
||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||
import {
|
||
buildDetailUiProtocol,
|
||
buildDetailUiReviewPatch,
|
||
type DetailGenerationMode,
|
||
type DetailUiScreenReviewPatch,
|
||
} from "@/lib/detail-ui-protocol";
|
||
import { buildDetailUiSurfacePatchFromReview } from "@/lib/detail-ui-surface-adapter";
|
||
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
|
||
import type { UiSurfacePatch } from "@/lib/ui-surface-protocol";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 180;
|
||
|
||
type DetailReviewRequest = {
|
||
outputLanguage?: string;
|
||
generationMode?: string;
|
||
surfaceId?: string;
|
||
imageUrl?: string;
|
||
referenceImages?: string[];
|
||
screen?: {
|
||
index?: number;
|
||
title?: string;
|
||
subtitle?: string;
|
||
copy?: string;
|
||
layoutNote?: string;
|
||
visualPrompt?: string;
|
||
screenType?: string;
|
||
};
|
||
brief?: {
|
||
productName?: string;
|
||
targetAudience?: string;
|
||
usageScenario?: string;
|
||
coreNeed?: string;
|
||
coreSellingPoint?: string;
|
||
};
|
||
globalStyle?: {
|
||
designLanguage?: string;
|
||
typography?: {
|
||
fontFamily?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
body?: string;
|
||
};
|
||
layout?: {
|
||
grid?: string;
|
||
safeArea?: string;
|
||
rhythm?: string;
|
||
};
|
||
palette?: string;
|
||
};
|
||
};
|
||
|
||
function supportsVisionReview(model: {
|
||
supportsVision?: boolean | null;
|
||
capabilities?: unknown;
|
||
defaultParams?: unknown;
|
||
}) {
|
||
return (
|
||
Boolean(model.supportsVision) ||
|
||
isAiModelMultimodal({
|
||
capabilities: model.capabilities,
|
||
defaultParams: model.defaultParams,
|
||
})
|
||
);
|
||
}
|
||
|
||
function isVolcanoResponsesReviewer(config: {
|
||
provider?: { type?: string | null; baseUrl?: string | null } | null;
|
||
}) {
|
||
if (!config?.provider || config.provider.type !== "custom") return false;
|
||
const baseUrl = String(config.provider.baseUrl || "").toLowerCase();
|
||
return baseUrl.includes("ark.cn-beijing.volces.com") || /\/api\/v3(\/responses)?\/?$/.test(baseUrl);
|
||
}
|
||
|
||
function isAbortError(error: unknown): boolean {
|
||
const target = error as {
|
||
name?: unknown;
|
||
message?: unknown;
|
||
code?: unknown;
|
||
ABORT_ERR?: unknown;
|
||
};
|
||
const name = String(target?.name || "");
|
||
const message = String(target?.message || "");
|
||
const code = Number(target?.code);
|
||
const abortCode = Number(target?.ABORT_ERR);
|
||
return (
|
||
name === "AbortError" ||
|
||
code === 20 ||
|
||
abortCode === 20 ||
|
||
/aborted|abort/i.test(message)
|
||
);
|
||
}
|
||
|
||
function normalizeLanguage(value: unknown): "zh" | "en" {
|
||
const raw = String(value || "").toLowerCase();
|
||
if (raw.includes("en") || raw.includes("english")) return "en";
|
||
return "zh";
|
||
}
|
||
|
||
function sanitizeImageUrls(value: unknown, limit = 8): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value
|
||
.map((item) => String(item || "").trim())
|
||
.filter((url) => /^https?:\/\//i.test(url))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
||
const matched = text.match(/\{[\s\S]*\}/);
|
||
if (!matched) return null;
|
||
try {
|
||
return JSON.parse(matched[0]) as T;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildFallbackPrompt(params: {
|
||
screenPrompt: string;
|
||
layoutNote: string;
|
||
language: "zh" | "en";
|
||
isFirstScreen: boolean;
|
||
}) {
|
||
if (params.language === "en") {
|
||
return [
|
||
params.screenPrompt,
|
||
"Second-pass optimization: improve typography hierarchy and layout readability.",
|
||
"Keep fixed canvas 790x1300.",
|
||
"Typography lock: title 64px/700, subtitle 34px/600, body 24px/500, keep the same hierarchy used in all other frames.",
|
||
"Text safe-area lock: y=64~360, avoid text overlap with product hero.",
|
||
params.layoutNote ? `Layout fix note: ${params.layoutNote}` : "",
|
||
params.isFirstScreen
|
||
? "Screen 1 hero-cover lock: poster-like key visual, single dominant product focal point, clear top headline safe area, no collage modules."
|
||
: "",
|
||
"Do not put 'Screen X' text in the title.",
|
||
"Marketing conversion lock: pain point -> benefit -> trust proof path must be immediately understandable.",
|
||
"Preserve product identity and style consistency with previous frames.",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
return [
|
||
params.screenPrompt,
|
||
"二次修图要求:重点优化标题层级、版式留白与可读性。",
|
||
"画布固定 790x1300。",
|
||
"字体锁定:主标题64px/700,副标题34px/600,正文24px/500,全套层级与其它屏一致。",
|
||
"文字安全区锁定:y=64~360,避免与产品主体重叠。",
|
||
params.layoutNote ? `排版修正建议:${params.layoutNote}` : "",
|
||
params.isFirstScreen
|
||
? "首屏封面锁定:必须是海报主视觉,单一主体强焦点,顶部标题区清晰,禁止拼贴卡片说明。"
|
||
: "",
|
||
"主标题不得出现“第X屏”等编号词。",
|
||
"营销转化锁定:用户能快速读懂“痛点-利益-证据”路径,避免只好看不转化。",
|
||
"保持产品外观与整体风格连续一致。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const body = (await request.json()) as DetailReviewRequest;
|
||
const language = normalizeLanguage(body.outputLanguage);
|
||
const generationMode: DetailGenerationMode =
|
||
body.generationMode === "agent_layout" ? "agent_layout" : "classic";
|
||
const imageUrl = String(body.imageUrl || "").trim();
|
||
if (!/^https?:\/\//i.test(imageUrl)) {
|
||
return NextResponse.json({ success: false, error: "缺少待质检图片" }, { status: 400 });
|
||
}
|
||
|
||
const screenPrompt = String(body.screen?.visualPrompt || "").trim();
|
||
const layoutNote = String(body.screen?.layoutNote || "").trim();
|
||
const referenceImages = sanitizeImageUrls(body.referenceImages);
|
||
const [allModels, agentModel, currentSiteSettings] = await Promise.all([
|
||
getEnabledAiModelsWithProvider(false),
|
||
getAgentAiModelWithProvider(),
|
||
getSiteSettings(),
|
||
]);
|
||
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
|
||
siteSettingModelId: currentSiteSettings?.canvasDetailAgentModelId,
|
||
});
|
||
const reviewCompatible = allModels.filter(
|
||
(item) =>
|
||
!item.model.isSubModel &&
|
||
item.model.isEnabled &&
|
||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||
);
|
||
const configuredDetailAgentConfig =
|
||
currentSiteSettings?.canvasDetailAgentModelId &&
|
||
reviewCompatible.some(
|
||
(item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId,
|
||
)
|
||
? reviewCompatible.find(
|
||
(item) => item.model.id === currentSiteSettings.canvasDetailAgentModelId,
|
||
)
|
||
: undefined;
|
||
|
||
const fallbackAgentConfig =
|
||
agentModel &&
|
||
reviewCompatible.some((item) => item.model.id === agentModel.model.id)
|
||
? reviewCompatible.find((item) => item.model.id === agentModel.model.id)
|
||
: undefined;
|
||
let reviewerConfig = configuredDetailAgentConfig || fallbackAgentConfig;
|
||
if (!localDetailAgentResolved && (!reviewerConfig || !supportsVisionReview(reviewerConfig.model))) {
|
||
reviewerConfig = reviewCompatible.find((item) => supportsVisionReview(item.model));
|
||
}
|
||
|
||
const fallbackPrompt = buildFallbackPrompt({
|
||
screenPrompt,
|
||
layoutNote,
|
||
language,
|
||
isFirstScreen: Number(body.screen?.index || 0) === 1,
|
||
});
|
||
const currentUiProtocol = buildDetailUiProtocol({
|
||
generationMode,
|
||
screenSize: {
|
||
width: 790,
|
||
height: 1300,
|
||
},
|
||
globalStyle: body.globalStyle,
|
||
screens: [
|
||
{
|
||
index: Number(body.screen?.index || 1),
|
||
title: String(body.screen?.title || "").trim(),
|
||
subtitle: String(body.screen?.subtitle || "").trim(),
|
||
copy: String(body.screen?.copy || "").trim(),
|
||
screenType:
|
||
body.screen?.screenType === "A" ||
|
||
body.screen?.screenType === "B" ||
|
||
body.screen?.screenType === "C"
|
||
? body.screen.screenType
|
||
: undefined,
|
||
objective: undefined,
|
||
layoutNote: layoutNote,
|
||
},
|
||
],
|
||
});
|
||
const currentUiValidation = validateDetailUiProtocol(currentUiProtocol);
|
||
|
||
if (!localDetailAgentResolved && !reviewerConfig) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
pass: true,
|
||
reviewAction: "pass",
|
||
score: 80,
|
||
designDirectorScore: 80,
|
||
marketingDirectorScore: 80,
|
||
issues: [],
|
||
layoutFixes: [],
|
||
revisedPrompt: fallbackPrompt,
|
||
uiPatch: undefined,
|
||
uiSurfacePatch: undefined,
|
||
model: null,
|
||
},
|
||
});
|
||
}
|
||
|
||
const selectedModelRef = localDetailAgentResolved
|
||
? localDetailAgentResolved.modelRef
|
||
: reviewerConfig!.model;
|
||
const selectedProviderRef = localDetailAgentResolved
|
||
? localDetailAgentResolved.providerRef
|
||
: {
|
||
id: reviewerConfig!.provider.id,
|
||
name: reviewerConfig!.provider.name,
|
||
type: reviewerConfig!.provider.type,
|
||
baseUrl: reviewerConfig!.provider.baseUrl,
|
||
};
|
||
const reviewKimiOptions = buildDetailKimiGenerateOptions({
|
||
purpose: "review",
|
||
generationMode,
|
||
modelId: selectedModelRef.modelId,
|
||
providerBaseUrl: selectedProviderRef.baseUrl,
|
||
isLocalMoonshot: Boolean(localDetailAgentResolved),
|
||
});
|
||
const codingModelId = localDetailAgentResolved
|
||
? null
|
||
: getAiModelAgentCodingModelId(reviewerConfig!.model);
|
||
const model = localDetailAgentResolved
|
||
? localDetailAgentResolved.model
|
||
: await getLanguageModelById(
|
||
reviewerConfig!.model.id,
|
||
codingModelId
|
||
);
|
||
console.log("[Canvas Detail Review] Reviewer model selected:", {
|
||
id: selectedModelRef.id,
|
||
displayName: selectedModelRef.displayName,
|
||
modelId: selectedModelRef.modelId,
|
||
codingModelId,
|
||
providerType: selectedProviderRef.type,
|
||
supportsVision: selectedModelRef.supportsVision,
|
||
detailAgentSettingModelId: currentSiteSettings?.canvasDetailAgentModelId || null,
|
||
selectionSource:
|
||
localDetailAgentResolved
|
||
? "detail-agent-local-config"
|
||
: configuredDetailAgentConfig?.model.id === reviewerConfig?.model.id
|
||
? "detail-agent-setting"
|
||
: fallbackAgentConfig?.model.id === reviewerConfig?.model.id
|
||
? "agent-default"
|
||
: "vision-fallback",
|
||
kimiDetailProfile: reviewKimiOptions.headers
|
||
? {
|
||
thinking: reviewKimiOptions.headers["x-detail-agent-kimi-thinking"],
|
||
maxTokens: reviewKimiOptions.maxTokens,
|
||
}
|
||
: null,
|
||
});
|
||
if (!model) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
pass: true,
|
||
reviewAction: "pass",
|
||
score: 80,
|
||
designDirectorScore: 80,
|
||
marketingDirectorScore: 80,
|
||
issues: [],
|
||
layoutFixes: [],
|
||
revisedPrompt: fallbackPrompt,
|
||
uiPatch: undefined,
|
||
uiSurfacePatch: undefined,
|
||
model: null,
|
||
},
|
||
});
|
||
}
|
||
|
||
const promptParts = [
|
||
language === "en"
|
||
? "Review this generated e-commerce detail frame and decide whether a second-pass regeneration is needed. Use dual-director standards."
|
||
: "请检查这张已生成的电商详情图,判断是否需要二次修图。按双总监标准审查。",
|
||
language === "en"
|
||
? "Role A (Art Director): check visual hierarchy, typography consistency, spacing rhythm, composition balance, and premium design quality."
|
||
: "角色A(平面设计总监):检查视觉层级、字体统一性、留白节奏、构图平衡、设计高级感。",
|
||
language === "en"
|
||
? "Role B (Marketing Director): check conversion clarity, pain-point to benefit translation, trust proof strength, and purchase-driving message clarity."
|
||
: "角色B(营销总监):检查转化清晰度、痛点到买点转译、证据可信度、购买驱动表达。",
|
||
language === "en" ? "Input constraints:" : "输入约束:",
|
||
`- Canvas fixed: 790x1300`,
|
||
`- Title: ${String(body.globalStyle?.typography?.title || "").trim()}`,
|
||
`- Subtitle: ${String(body.globalStyle?.typography?.subtitle || "").trim()}`,
|
||
`- Body: ${String(body.globalStyle?.typography?.body || "").trim()}`,
|
||
`- Safe area: ${String(body.globalStyle?.layout?.safeArea || "").trim()}`,
|
||
`- Rhythm: ${String(body.globalStyle?.layout?.rhythm || "").trim()}`,
|
||
`- Screen title: ${String(body.screen?.title || "").trim()}`,
|
||
`- Screen subtitle: ${String(body.screen?.subtitle || "").trim()}`,
|
||
`- Screen copy: ${String(body.screen?.copy || "").trim()}`,
|
||
`- Layout note: ${layoutNote}`,
|
||
currentUiValidation.issues.length > 0
|
||
? `- Current protocol validation issues: ${currentUiValidation.issues
|
||
.map((item) => item.message)
|
||
.join(" | ")}`
|
||
: language === "en"
|
||
? "- Current protocol validation issues: none"
|
||
: "- 当前协议校验问题:无",
|
||
Number(body.screen?.index || 0) === 1
|
||
? "- Screen 1 must keep poster hero quality: single dominant focal point, clear headline zone, no collage."
|
||
: "",
|
||
screenPrompt ? `- Source prompt: ${screenPrompt}` : "",
|
||
language === "en"
|
||
? "Return strict JSON: {\"pass\":boolean,\"score\":0-100,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"layoutFixes\":[\"...\"],\"typographyFixes\":[\"...\"],\"conversionFixes\":[\"...\"],\"revisedPrompt\":\"...\"}"
|
||
: "仅返回严格JSON:{\"pass\":boolean,\"score\":0-100,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"layoutFixes\":[\"...\"],\"typographyFixes\":[\"...\"],\"conversionFixes\":[\"...\"],\"revisedPrompt\":\"...\"}",
|
||
language === "en"
|
||
? "If pass=false, revisedPrompt must be directly usable for image regeneration and include typography and safe-area fixes."
|
||
: "若 pass=false,revisedPrompt 必须可直接用于生图,且明确包含字体层级、安全区和营销转化修正。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
|
||
const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6);
|
||
let effectiveReviewImages = reviewImageCandidates;
|
||
const mustInlineForKimi = Boolean(reviewKimiOptions.headers);
|
||
const shouldInlineReviewImages =
|
||
mustInlineForKimi ||
|
||
(!localDetailAgentResolved &&
|
||
reviewerConfig &&
|
||
!isVolcanoResponsesReviewer(reviewerConfig));
|
||
if (shouldInlineReviewImages) {
|
||
const { inlineDataUrls, skipped } = await inlineVisionImages(reviewImageCandidates, {
|
||
maxImages: 6,
|
||
maxSide: 900,
|
||
timeoutMs: 12000,
|
||
retries: 2,
|
||
loggerPrefix: "[Canvas Detail Review]",
|
||
});
|
||
effectiveReviewImages =
|
||
inlineDataUrls.length > 0
|
||
? inlineDataUrls
|
||
: mustInlineForKimi
|
||
? []
|
||
: reviewImageCandidates;
|
||
if (mustInlineForKimi && skipped.length > 0 && inlineDataUrls.length === 0) {
|
||
console.warn(
|
||
"[Canvas Detail Review] Kimi K2.6 image inputs skipped after inline failed:",
|
||
skipped
|
||
);
|
||
}
|
||
}
|
||
|
||
const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [
|
||
{ type: "text", text: promptParts },
|
||
...effectiveReviewImages.map((image) => ({ type: "image" as const, image })),
|
||
];
|
||
|
||
let text = "";
|
||
try {
|
||
const result = await generateText({
|
||
model,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content,
|
||
},
|
||
],
|
||
system:
|
||
language === "en"
|
||
? "You are both an Art Director and a Marketing Director for high-conversion e-commerce detail pages. Evaluate with strict professional standards and output JSON only."
|
||
: "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。",
|
||
temperature: localDetailAgentResolved ? 1 : 0.1,
|
||
maxTokens: 800,
|
||
...reviewKimiOptions,
|
||
} as any);
|
||
text = String(result?.text || "");
|
||
} catch (error) {
|
||
const message = String((error as Error)?.message || "");
|
||
if (isAbortError(error) || /invalid scheme/i.test(message)) {
|
||
const fallbackIssues = isAbortError(error)
|
||
? ["质检模型超时,已跳过二次审稿并保留本屏结果。"]
|
||
: ["质检模型图片输入协议不兼容,已跳过二次审稿并保留本屏结果。"];
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
pass: true,
|
||
reviewAction: "pass",
|
||
score: 78,
|
||
designDirectorScore: 78,
|
||
marketingDirectorScore: 78,
|
||
issues: fallbackIssues,
|
||
layoutFixes: [],
|
||
revisedPrompt: fallbackPrompt,
|
||
uiPatch: undefined,
|
||
uiSurfacePatch: undefined,
|
||
model: {
|
||
id: selectedModelRef.id,
|
||
displayName: selectedModelRef.displayName,
|
||
providerType: selectedProviderRef.type,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const parsed = extractJsonBlock<{
|
||
pass?: unknown;
|
||
score?: unknown;
|
||
designDirectorScore?: unknown;
|
||
marketingDirectorScore?: unknown;
|
||
issues?: unknown;
|
||
layoutFixes?: unknown;
|
||
typographyFixes?: unknown;
|
||
conversionFixes?: unknown;
|
||
revisedPrompt?: unknown;
|
||
}>(text || "");
|
||
const designDirectorScoreRaw = Number(parsed?.designDirectorScore);
|
||
const marketingDirectorScoreRaw = Number(parsed?.marketingDirectorScore);
|
||
const designDirectorScore = Number.isFinite(designDirectorScoreRaw)
|
||
? Math.max(0, Math.min(100, Math.round(designDirectorScoreRaw)))
|
||
: 80;
|
||
const marketingDirectorScore = Number.isFinite(marketingDirectorScoreRaw)
|
||
? Math.max(0, Math.min(100, Math.round(marketingDirectorScoreRaw)))
|
||
: 80;
|
||
const scoreRaw = Number(parsed?.score);
|
||
const score = Number.isFinite(scoreRaw)
|
||
? Math.max(0, Math.min(100, Math.round(scoreRaw)))
|
||
: Math.round((designDirectorScore + marketingDirectorScore) / 2);
|
||
const issues = Array.isArray(parsed?.issues)
|
||
? parsed.issues.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 6)
|
||
: [];
|
||
const layoutFixes = Array.isArray(parsed?.layoutFixes)
|
||
? parsed.layoutFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 6)
|
||
: [];
|
||
const typographyFixes = Array.isArray(parsed?.typographyFixes)
|
||
? parsed.typographyFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
||
: [];
|
||
const conversionFixes = Array.isArray(parsed?.conversionFixes)
|
||
? parsed.conversionFixes.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
||
: [];
|
||
const revisedPromptRaw = String(parsed?.revisedPrompt || "").trim();
|
||
const revisedPrompt =
|
||
revisedPromptRaw.length > 30
|
||
? revisedPromptRaw
|
||
: [
|
||
fallbackPrompt,
|
||
...layoutFixes.map((item) => `- ${item}`),
|
||
...typographyFixes.map((item) => `- ${item}`),
|
||
...conversionFixes.map((item) => `- ${item}`),
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
|
||
const passByFlag = typeof parsed?.pass === "boolean" ? parsed.pass : undefined;
|
||
const pass =
|
||
passByFlag ??
|
||
(issues.length === 0 &&
|
||
score >= 86 &&
|
||
designDirectorScore >= 85 &&
|
||
marketingDirectorScore >= 85);
|
||
|
||
const uiPatch: DetailUiScreenReviewPatch | undefined = buildDetailUiReviewPatch({
|
||
generationMode,
|
||
screenSize: {
|
||
width: 790,
|
||
height: 1300,
|
||
},
|
||
globalStyle: body.globalStyle,
|
||
screen: {
|
||
index: Number(body.screen?.index || 1),
|
||
title: String(body.screen?.title || "").trim(),
|
||
subtitle: String(body.screen?.subtitle || "").trim(),
|
||
copy: String(body.screen?.copy || "").trim(),
|
||
screenType:
|
||
body.screen?.screenType === "A" ||
|
||
body.screen?.screenType === "B" ||
|
||
body.screen?.screenType === "C"
|
||
? body.screen.screenType
|
||
: undefined,
|
||
objective: undefined,
|
||
layoutNote: String(body.screen?.layoutNote || "").trim(),
|
||
},
|
||
layoutFixes,
|
||
typographyFixes,
|
||
conversionFixes,
|
||
});
|
||
const uiValidation = validateDetailUiProtocol({
|
||
version: "detail-ui-protocol/v1",
|
||
mode: generationMode,
|
||
screenSize: {
|
||
width: 790,
|
||
height: 1300,
|
||
},
|
||
screens: uiPatch ? [uiPatch.recommendedScreen] : [],
|
||
globalStyle: body.globalStyle,
|
||
});
|
||
const reviewAction =
|
||
pass
|
||
? "pass"
|
||
: generationMode === "agent_layout" &&
|
||
(uiPatch.notes.length > 0 || !uiValidation.passed)
|
||
? "protocol_patch"
|
||
: "regenerate_image";
|
||
const uiSurfacePatch: UiSurfacePatch | null =
|
||
generationMode === "agent_layout" &&
|
||
reviewAction === "protocol_patch" &&
|
||
uiPatch &&
|
||
body.surfaceId
|
||
? buildDetailUiSurfacePatchFromReview({
|
||
surfaceId: body.surfaceId,
|
||
screenIndex: Number(body.screen?.index || 1),
|
||
screen: {
|
||
index: Number(body.screen?.index || 1),
|
||
title: String(body.screen?.title || "").trim(),
|
||
subtitle: String(body.screen?.subtitle || "").trim(),
|
||
copy: String(body.screen?.copy || "").trim(),
|
||
screenType:
|
||
body.screen?.screenType === "A" ||
|
||
body.screen?.screenType === "B" ||
|
||
body.screen?.screenType === "C"
|
||
? body.screen.screenType
|
||
: undefined,
|
||
objective: undefined,
|
||
layoutNote: String(body.screen?.layoutNote || "").trim(),
|
||
},
|
||
uiPatch,
|
||
uiValidation: {
|
||
passed: uiValidation.passed,
|
||
issues: uiValidation.issues.map((item) => item.message),
|
||
},
|
||
})
|
||
: null;
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
pass,
|
||
reviewAction,
|
||
score,
|
||
designDirectorScore,
|
||
marketingDirectorScore,
|
||
issues,
|
||
layoutFixes: [...layoutFixes, ...typographyFixes, ...conversionFixes].slice(0, 10),
|
||
revisedPrompt,
|
||
uiPatch,
|
||
uiSurfacePatch: uiSurfacePatch || undefined,
|
||
uiValidation: {
|
||
passed: uiValidation.passed,
|
||
issues: uiValidation.issues.map((item) => item.message),
|
||
},
|
||
model: {
|
||
id: selectedModelRef.id,
|
||
displayName: selectedModelRef.displayName,
|
||
providerType: selectedProviderRef.type,
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("[Canvas Detail Review] Error:", error);
|
||
return NextResponse.json(
|
||
{ success: false, error: error instanceof Error ? error.message : "详情质检失败" },
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
}
|