Your Name 3101f5966d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: enhance canvas agent UI customization
2026-04-23 13:25:05 +08:00

635 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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