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>(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 }, ); } }