优化AI详情生成Agent流程与PC工作台体验
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-15 20:18:28 +08:00
parent 12704ffd58
commit 2793582fd1
5 changed files with 2076 additions and 23 deletions

View File

@ -193,6 +193,7 @@ type SiteSettings = {
tutorialEnabled: boolean;
rankingEnabled: boolean;
canvasEnabled: boolean;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
lovartComposerPrefix: string;
@ -432,6 +433,7 @@ export function AdminSettingsClient() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
lovartComposerPrefix: "让Buyi-ai",
@ -496,6 +498,7 @@ export function AdminSettingsClient() {
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
lovartComposerPrefix:
@ -4144,6 +4147,22 @@ export function AdminSettingsClient() {
</div>
{siteSettings.canvasEnabled && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="aiDetailGenerationEnabled">AI详情生成功能</Label>
<div className="text-xs text-muted-foreground">
开启后,无限画布前端会显示“AI详情生成”入口;关闭后前端隐藏该功能。
</div>
</div>
<Switch
id="aiDetailGenerationEnabled"
checked={siteSettings.aiDetailGenerationEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, aiDetailGenerationEnabled: checked })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="canvasDefaultModelId">画布默认生图模型</Label>
<Select

View File

@ -0,0 +1,781 @@
import { NextResponse } from "next/server";
import { generateText } from "ai";
import { auth } from "@/app/(auth)/auth";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
} from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
export const runtime = "nodejs";
export const maxDuration = 60;
type DetailPlanRequest = {
prompt?: string;
count?: number;
outputLanguage?: string;
referenceImages?: string[];
referenceImageNotes?: string[];
modelImageUrl?: string;
brief?: {
productName?: string;
targetAudience?: string;
mainUser?: string;
usageScenario?: string;
triggerMoment?: string;
coreNeed?: string;
riskConcern?: string;
expectedResult?: string;
coreSellingPoint?: string;
positioningStatement?: string;
userVoice?: string;
competitorReference?: string;
priceBand?: string;
brandTone?: string;
bannedWords?: string;
};
};
type DetailBrief = {
productName: string;
targetAudience: string;
mainUser: string;
usageScenario: string;
triggerMoment: string;
coreNeed: string;
riskConcern: string;
expectedResult: string;
coreSellingPoint: string;
positioningStatement: string;
userVoice: string;
competitorReference: string;
priceBand: string;
brandTone: string;
bannedWords: string;
};
type DetailScreenType = "A" | "B" | "C";
type DetailPlanScreen = {
index: number;
screenType: DetailScreenType;
objective: string;
title: string;
subtitle: string;
copy: string;
layoutNote: string;
useModel: boolean;
modelReason: string;
visualPrompt: string;
fabe: {
b: string;
f: string;
a: string;
e: string;
};
};
type DetailPlanResponse = {
summary: string;
prompts: string[];
screenSize: {
width: number;
height: number;
};
globalStyle: {
designLanguage: string;
typography: {
fontFamily: string;
title: string;
subtitle: string;
body: string;
};
layout: {
grid: string;
safeArea: string;
rhythm: string;
};
palette: string;
};
skillFlow: {
name: string;
version: string;
steps: string[];
};
qualityChecks: {
passed: boolean;
issues: string[];
};
screens: DetailPlanScreen[];
model?: {
id: string;
displayName: string;
providerType: string;
};
};
function buildDetailSkillFlow(language: "zh" | "en") {
if (language === "en") {
return {
name: "AI Detail Generation Skill",
version: "1.0",
steps: [
"Positioning Card: identify buyer, user, scenario, trigger, risk, expected outcome.",
"Need Decomposition: prioritize core demand and convert feature to buying reason.",
"Golden Five-Screen Structure: core value, differentiation, trust proof.",
"FABE Copying: each frame includes Benefit, Feature, Advantage, Evidence.",
"A/B/C Visual Rhythm: emotional scene + rational proof + conversion closure.",
"Global Consistency: fixed size, unified typography hierarchy, layout rhythm and style.",
],
};
}
return {
name: "AI详情生成技能",
version: "1.0",
steps: [
"定位卡:明确购买人群、使用者、主场景、触发时刻、顾虑与期望结果。",
"需求拆解:按核心需求优先级,把卖点转成买点表达。",
"黄金五屏:前2屏讲核心卖点,3-4屏做差异强化,第5屏证据收口。",
"FABE文案:每屏都输出利益(B)、特征(F)、优势(A)、证据(E)。",
"A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾。",
"全局一致性:固定尺寸、统一字体层级、统一排版节奏与设计风格。",
],
};
}
function clampCount(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) return 5;
return Math.min(12, Math.max(5, Math.round(num)));
}
function sanitizeReferenceImages(images: unknown): string[] {
if (!Array.isArray(images)) return [];
return images
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6);
}
function sanitizeReferenceImageNotes(notes: unknown): string[] {
if (!Array.isArray(notes)) return [];
return notes
.map((item) => String(item || "").replace(/\s+/g, " ").trim())
.filter(Boolean)
.slice(0, 6);
}
function normalizeOutputLanguage(value: unknown): "zh" | "en" {
const raw = String(value || "").toLowerCase();
if (raw.includes("en") || raw.includes("english") || raw.includes("英文")) {
return "en";
}
return "zh";
}
function normalizeDetailBrief(value: unknown): DetailBrief {
const raw = (value || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
return {
productName: normalize("productName"),
targetAudience: normalize("targetAudience"),
mainUser: normalize("mainUser"),
usageScenario: normalize("usageScenario"),
triggerMoment: normalize("triggerMoment"),
coreNeed: normalize("coreNeed"),
riskConcern: normalize("riskConcern"),
expectedResult: normalize("expectedResult"),
coreSellingPoint: normalize("coreSellingPoint"),
positioningStatement: normalize("positioningStatement"),
userVoice: normalize("userVoice"),
competitorReference: normalize("competitorReference"),
priceBand: normalize("priceBand"),
brandTone: normalize("brandTone"),
bannedWords: normalize("bannedWords"),
};
}
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 buildBriefText(brief: DetailBrief, language: "zh" | "en") {
const lines =
language === "en"
? [
brief.productName ? `Product: ${brief.productName}` : "",
`Primary buyer audience: ${brief.targetAudience}`,
brief.mainUser ? `Primary user: ${brief.mainUser}` : "",
`Main usage scenario: ${brief.usageScenario}`,
brief.triggerMoment ? `Trigger moment: ${brief.triggerMoment}` : "",
`Core demand: ${brief.coreNeed}`,
brief.riskConcern ? `Risk concern: ${brief.riskConcern}` : "",
brief.expectedResult ? `Expected result: ${brief.expectedResult}` : "",
brief.coreSellingPoint ? `Core mental keyword: ${brief.coreSellingPoint}` : "",
brief.positioningStatement ? `Positioning statement: ${brief.positioningStatement}` : "",
brief.userVoice ? `User voice samples: ${brief.userVoice}` : "",
brief.competitorReference ? `Competitor reference: ${brief.competitorReference}` : "",
brief.priceBand ? `Price band: ${brief.priceBand}` : "",
brief.brandTone ? `Brand tone: ${brief.brandTone}` : "",
brief.bannedWords ? `Banned words: ${brief.bannedWords}` : "",
]
: [
brief.productName ? `产品:${brief.productName}` : "",
`主购买人群:${brief.targetAudience}`,
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
`主场景:${brief.usageScenario}`,
brief.triggerMoment ? `触发时刻:${brief.triggerMoment}` : "",
`核心需求:${brief.coreNeed}`,
brief.riskConcern ? `用户顾虑:${brief.riskConcern}` : "",
brief.expectedResult ? `期望结果:${brief.expectedResult}` : "",
brief.coreSellingPoint ? `核心心智词:${brief.coreSellingPoint}` : "",
brief.positioningStatement ? `一句话定位:${brief.positioningStatement}` : "",
brief.userVoice ? `用户原话样本:${brief.userVoice}` : "",
brief.competitorReference ? `竞品对标:${brief.competitorReference}` : "",
brief.priceBand ? `价格带:${brief.priceBand}` : "",
brief.brandTone ? `品牌语气:${brief.brandTone}` : "",
brief.bannedWords ? `禁用词:${brief.bannedWords}` : "",
];
return lines.filter(Boolean).join("\n");
}
function buildDefaultGlobalStyle(language: "zh" | "en") {
if (language === "en") {
return {
designLanguage: "Clean premium e-commerce detail storytelling",
typography: {
fontFamily: "Unified modern sans-serif family across all frames",
title: "Main title: same weight/size hierarchy, bold and concise",
subtitle: "Subtitle: one level below title with stable spacing",
body: "Body text: readable medium-small size with consistent leading",
},
layout: {
grid: "Unified 12-column vertical layout with consistent alignment",
safeArea: "Top title area, middle product focus area, bottom proof area",
rhythm: "Use identical margin/padding rhythm across all frames",
},
palette: "Keep one unified palette with stable contrast and accent color",
};
}
return {
designLanguage: "高转化电商详情叙事风格(统一、克制、专业)",
typography: {
fontFamily: "全套统一现代无衬线字体家族,不混用",
title: "主标题使用统一字号/字重层级,简短有力",
subtitle: "副标题保持同一层级与间距,承接主标题",
body: "正文说明统一行高与字距,保证可读性",
},
layout: {
grid: "统一 12 栅格竖版布局,所有屏幕对齐关系一致",
safeArea: "上区标题、中区产品主体、下区证据与转化信息",
rhythm: "边距与留白节奏全套一致,避免每屏乱跳",
},
palette: "全套保持同一主色/强调色/中性色体系,明暗对比稳定",
};
}
function buildFallbackScreens(params: {
prompt: string;
count: number;
language: "zh" | "en";
brief: DetailBrief;
globalStyle: DetailPlanResponse["globalStyle"];
referenceImageNotes: string[];
hasModelImage: boolean;
}): DetailPlanScreen[] {
const { prompt, count, language, brief, globalStyle, referenceImageNotes, hasModelImage } = params;
const productLabel =
brief.productName || (language === "en" ? "the product" : "该产品");
const keyword =
brief.coreSellingPoint || (language === "en" ? "core value" : "核心价值");
const mainScenario = brief.usageScenario || (language === "en" ? "daily life" : "日常场景");
const coreNeed = brief.coreNeed || (language === "en" ? "user demand" : "核心需求");
const positioning =
brief.positioningStatement || (language === "en" ? "No explicit positioning statement" : "未提供一句话定位");
const defaultObjectives =
language === "en"
? [
"Hero scene with one core value",
"Pain-point resonance and trigger moment",
"Solution structure and key mechanism",
"Differentiation reinforcement",
"Evidence and trust closing",
]
: [
"首屏主视觉聚焦单一核心卖点",
"痛点共鸣与触发场景放大",
"解决方案与关键机制说明",
"差异化优势重复强化",
"证据背书与信任收口",
];
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
return Array.from({ length: count }).map((_, index) => {
const screenType =
screenTypeSequence[index] || (index % 3 === 0 ? "B" : index % 3 === 1 ? "A" : "C");
const objective =
defaultObjectives[index] ||
(language === "en"
? `Extended detail frame ${index + 1}`
: `延展分镜 ${index + 1}`);
const title =
language === "en"
? `${keyword} Frame ${index + 1}`
: `${keyword} 第${index + 1}屏`;
const subtitle =
language === "en"
? `For ${brief.targetAudience}, solve "${coreNeed}" in ${mainScenario}.`
: `面向${brief.targetAudience},在${mainScenario}场景解决“${coreNeed}”。`;
const copy =
language === "en"
? `${productLabel} turns key pain points into visible outcomes with a clear decision path.`
: `${productLabel}将关键痛点转为可感知结果,形成清晰购买决策路径。`;
const layoutNote =
language === "en"
? `Use fixed layout rhythm. Keep title/subtitle/body hierarchy identical to all other frames.`
: `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致。`;
const useModel = hasModelImage && (index === 1 || index === 3 || index >= 5);
const modelReason = useModel
? language === "en"
? "This frame needs human scenario demonstration, so use the fixed model image."
: "本屏需要人物场景演示,使用固定模特图。"
: language === "en"
? "This frame focuses on product-only communication without human figure."
: "本屏聚焦产品信息展示,不使用人物。";
const fabe =
language === "en"
? {
b: `Get ${coreNeed} resolved with less effort and risk.`,
f: `Show concrete product structure and material facts.`,
a: `Outperform generic options in core scenario performance.`,
e: `Use test data, certification, and before-after comparisons.`,
}
: {
b: `用户可低成本获得“${coreNeed}”的结果。`,
f: "给出可验证的结构、材质或工艺事实。",
a: "强调在主场景下优于普通方案的关键优势。",
e: "使用检测、认证、对比图等证据收尾。",
};
const visualPrompt =
language === "en"
? [
`E-commerce detail frame ${index + 1}, objective: ${objective}.`,
`Fixed vertical canvas 790x1300 pixels.`,
`Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; audience: ${brief.targetAudience}; core demand: ${coreNeed}.`,
`Positioning statement: ${positioning}.`,
`Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
`Layout: ${globalStyle.layout.grid}; ${globalStyle.layout.safeArea}; ${globalStyle.layout.rhythm}.`,
`Palette: ${globalStyle.palette}.`,
referenceImageNotes.length > 0
? `Reference image set roles: ${referenceImageNotes.join(" | ")}. Keep product identity and details fully consistent.`
: "",
useModel
? "Integrate the same human model from the uploaded model image."
: "No human figure in this frame.",
`Commercial product photography, typography-safe area, no extra watermark.`,
`Task context: ${prompt}.`,
].join(" ")
: [
`电商详情页第${index + 1}屏,目标:${objective}。`,
"画布固定竖版 790x1300 像素。",
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};人群:${brief.targetAudience};核心需求:${coreNeed}。`,
`一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
`版式规范:${globalStyle.layout.grid};${globalStyle.layout.safeArea};${globalStyle.layout.rhythm}。`,
`配色规范:${globalStyle.palette}。`,
referenceImageNotes.length > 0
? `参考图角色:${referenceImageNotes.join(";")};必须严格保持产品一致性与细节一致性。`
: "",
useModel ? "本屏融入固定模特图中的同一人物。" : "本屏不要出现人物。",
"商业摄影质感,留出清晰文案排版区,不要水印。",
`任务上下文:${prompt}。`,
].join("");
return {
index: index + 1,
screenType,
objective,
title,
subtitle,
copy,
layoutNote,
useModel,
modelReason,
visualPrompt,
fabe,
};
});
}
function buildFallbackPlan(params: {
prompt: string;
count: number;
language: "zh" | "en";
brief: DetailBrief;
referenceImageNotes: string[];
hasModelImage: boolean;
}): DetailPlanResponse {
const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = buildDetailSkillFlow(language);
const screens = buildFallbackScreens({
...params,
globalStyle,
});
const qualityChecks = evaluatePlanQuality({
screens,
hasModelImage: params.hasModelImage,
});
return {
summary:
language === "en"
? `Generated ${count} storyboard frames with fixed size 790x1300.`
: `已生成 ${count} 段详情分镜,固定尺寸 790x1300。`,
prompts: screens.map((item) => item.visualPrompt),
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow,
qualityChecks,
screens,
};
}
function sanitizeScreen(
raw: unknown,
fallback: DetailPlanScreen
): DetailPlanScreen {
const source = (raw || {}) as Record<string, unknown>;
const safeText = (value: unknown, defaultValue: string) => {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
return normalized || defaultValue;
};
const fabeSource = (source.fabe || {}) as Record<string, unknown>;
const visualPrompt = safeText(source.visualPrompt, fallback.visualPrompt);
const rawScreenType = String(source.screenType || "").trim().toUpperCase();
const screenType: DetailScreenType =
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
? (rawScreenType as DetailScreenType)
: fallback.screenType;
return {
index: fallback.index,
screenType,
objective: safeText(source.objective, fallback.objective),
title: safeText(source.title, fallback.title),
subtitle: safeText(source.subtitle, fallback.subtitle),
copy: safeText(source.copy, fallback.copy),
layoutNote: safeText(source.layoutNote, fallback.layoutNote),
useModel:
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
modelReason: safeText(source.modelReason, fallback.modelReason),
visualPrompt: visualPrompt.includes("790x1300")
? visualPrompt
: `${visualPrompt} 画布固定 790x1300 像素。`,
fabe: {
b: safeText(fabeSource.b, fallback.fabe.b),
f: safeText(fabeSource.f, fallback.fabe.f),
a: safeText(fabeSource.a, fallback.fabe.a),
e: safeText(fabeSource.e, fallback.fabe.e),
},
};
}
function sanitizePlannedScreens(
raw: unknown,
fallbackScreens: DetailPlanScreen[],
): DetailPlanScreen[] {
if (!Array.isArray(raw)) {
return fallbackScreens;
}
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback));
}
function sanitizeGlobalStyle(
raw: unknown,
fallback: DetailPlanResponse["globalStyle"],
): DetailPlanResponse["globalStyle"] {
const source = (raw || {}) as Record<string, unknown>;
const toText = (value: unknown, defaultValue: string) => {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
return normalized || defaultValue;
};
const typography = (source.typography || {}) as Record<string, unknown>;
const layout = (source.layout || {}) as Record<string, unknown>;
return {
designLanguage: toText(source.designLanguage, fallback.designLanguage),
typography: {
fontFamily: toText(typography.fontFamily, fallback.typography.fontFamily),
title: toText(typography.title, fallback.typography.title),
subtitle: toText(typography.subtitle, fallback.typography.subtitle),
body: toText(typography.body, fallback.typography.body),
},
layout: {
grid: toText(layout.grid, fallback.layout.grid),
safeArea: toText(layout.safeArea, fallback.layout.safeArea),
rhythm: toText(layout.rhythm, fallback.layout.rhythm),
},
palette: toText(source.palette, fallback.palette),
};
}
function evaluatePlanQuality(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
}): { passed: boolean; issues: string[] } {
const { screens, hasModelImage } = params;
const issues: string[] = [];
if (screens.length === 0) {
issues.push("未生成任何分镜。");
}
const firstFive = screens.slice(0, 5);
if (firstFive.length >= 1 && firstFive[0]?.screenType !== "B") {
issues.push("第1屏应为B类场景共鸣页。");
}
if (firstFive.length >= 2 && firstFive[1]?.screenType !== "A") {
issues.push("第2屏建议为A类技术公信页。");
}
if (firstFive.length >= 4 && firstFive[3]?.screenType !== "C") {
issues.push("第4屏建议为C类细节佐证页。");
}
const typeCounter = firstFive.reduce(
(acc, screen) => {
acc[screen.screenType] += 1;
return acc;
},
{ A: 0, B: 0, C: 0 } as Record<DetailScreenType, number>,
);
if (typeCounter.B < 2) {
issues.push("前5屏应至少包含2屏B类场景页。");
}
if (typeCounter.A < 1) {
issues.push("前5屏应至少包含1屏A类公信页。");
}
if (typeCounter.C < 1) {
issues.push("前5屏应至少包含1屏C类细节页。");
}
const hasMissingFABE = screens.some(
(screen) =>
!screen.fabe.b.trim() ||
!screen.fabe.f.trim() ||
!screen.fabe.a.trim() ||
!screen.fabe.e.trim(),
);
if (hasMissingFABE) {
issues.push("存在分镜FABE不完整。");
}
if (hasModelImage) {
const modelScreenCount = screens.filter((screen) => screen.useModel).length;
if (modelScreenCount === 0) {
issues.push("已提供模特图但没有人物场景分镜。");
}
}
return {
passed: issues.length === 0,
issues,
};
}
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 DetailPlanRequest;
const prompt = String(body.prompt || "").trim();
if (!prompt) {
return NextResponse.json({ success: false, error: "请输入详情需求" }, { status: 400 });
}
const count = clampCount(body.count);
const referenceImages = sanitizeReferenceImages(body.referenceImages);
const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes);
const modelImageUrl = String(body.modelImageUrl || "").trim();
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
const language = normalizeOutputLanguage(body.outputLanguage);
const brief = normalizeDetailBrief(body.brief);
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
return NextResponse.json(
{
success: false,
error: "请补充必填项:主购买人群、需求场景、核心需求",
},
{ status: 400 },
);
}
const fallback = buildFallbackPlan({
prompt,
count,
language,
brief,
referenceImageNotes,
hasModelImage,
});
const allModels = await getEnabledAiModelsWithProvider(false);
const agentModel = await getAgentAiModelWithProvider();
const reasoningCompatible = allModels.filter(
(item) =>
!item.model.isSubModel &&
item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
);
const preferredAgentConfig =
agentModel &&
reasoningCompatible.some((item) => item.model.id === agentModel.model.id)
? reasoningCompatible.find((item) => item.model.id === agentModel.model.id)
: undefined;
let plannerConfig = preferredAgentConfig;
if (referenceImages.length > 0) {
if (!plannerConfig?.model.supportsVision) {
plannerConfig = reasoningCompatible.find((item) => item.model.supportsVision);
}
}
if (!plannerConfig) {
plannerConfig = reasoningCompatible[0];
}
if (!plannerConfig) {
return NextResponse.json({ success: true, data: fallback });
}
const model = await getLanguageModelById(plannerConfig.model.id);
if (!model) {
return NextResponse.json({ success: true, data: fallback });
}
const userInput = [
`用户需求:${prompt}`,
"以下是详情生成输入信息:",
buildBriefText(brief, language),
`目标分镜数量:${count}`,
`输出语言:${language === "en" ? "English" : "中文"}`,
"任务类型:电商详情页“AI详情生成”",
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
referenceImageNotes.length > 0
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
: "",
hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。",
referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。",
]
.filter(Boolean)
.join("\n");
const imageInputs =
plannerConfig.model.supportsVision && referenceImages.length > 0
? referenceImages.map((url) => ({ type: "image" as const, image: url }))
: [];
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: userInput,
},
...imageInputs,
],
},
],
system:
`你是资深电商视觉策划与提示词工程师。请为“AI详情生成”产出可直接用于生图的分镜提示词和文案。\n` +
`硬性要求:\n` +
`1) 输出严格 JSON,不要其他文本。\n` +
`2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` +
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
(hasModelImage
? `10) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
: `10) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
`输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
temperature: 0.25,
maxTokens: 1800,
} as any);
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || "");
const globalStyle = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens).map((item) =>
hasModelImage
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const qualityChecks = evaluatePlanQuality({
screens: rawScreens,
hasModelImage,
});
const screens = qualityChecks.passed ? rawScreens : fallback.screens;
const prompts = screens.map((item) => item.visualPrompt);
const summaryRaw = String(parsed?.summary || "").trim();
const summary = qualityChecks.passed
? summaryRaw || fallback.summary
: `${summaryRaw || fallback.summary}\n质量校验未通过,已自动回退到稳定分镜模板。`;
const response: DetailPlanResponse = {
summary,
prompts,
screenSize: {
width: 790,
height: 1300,
},
globalStyle,
skillFlow: fallback.skillFlow,
qualityChecks: qualityChecks.passed
? qualityChecks
: {
passed: false,
issues: [...qualityChecks.issues],
},
screens,
model: {
id: plannerConfig.model.id,
displayName: plannerConfig.model.displayName,
providerType: plannerConfig.provider.type,
},
};
return NextResponse.json({ success: true, data: response });
} catch (error) {
console.error("[Canvas Detail Plan] Error:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "详情规划失败" },
{ status: 500 },
);
}
}

View File

@ -15,6 +15,7 @@ interface GenerateImageRequest {
aspectRatio?: string; // 宽高比 "1:1", "16:9" 等
imageCount?: number; // 生成数量
referenceImage?: string; // 参考图片(用于图生图)
referenceImages?: string[]; // 多参考图(用于多角度/细节一致性)
}
interface ParsedImage {
@ -63,7 +64,15 @@ export async function POST(request: Request) {
}
const body: GenerateImageRequest = await request.json();
const { prompt, negativePrompt, model, aspectRatio = "1:1", imageCount = 1, referenceImage } = body;
const {
prompt,
negativePrompt,
model,
aspectRatio = "1:1",
imageCount = 1,
referenceImage,
referenceImages,
} = body;
if (!prompt) {
return NextResponse.json(
@ -85,7 +94,7 @@ export async function POST(request: Request) {
// 选择模型(如果指定了 model,使用指定的;否则使用默认的)
let selectedModel = model
? imageModels.find(m => m.model.modelId === model)
? imageModels.find(m => m.model.modelId === model || m.model.id === model)
: imageModels.find(m => m.model.isDefault) || imageModels[0];
if (!selectedModel) {
@ -100,13 +109,27 @@ export async function POST(request: Request) {
);
}
const normalizedReferenceImages = Array.from(
new Set(
[
...(Array.isArray(referenceImages) ? referenceImages : []),
...(referenceImage ? [referenceImage] : []),
]
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 6);
// 构建消息
const messages = [{
role: "user" as const,
content: referenceImage
content: normalizedReferenceImages.length > 0
? [
{ type: "text" as const, text: prompt },
{ type: "image_url" as const, image_url: { url: referenceImage } },
...normalizedReferenceImages.map((url) => ({
type: "image_url" as const,
image_url: { url },
})),
]
: prompt,
}];

View File

@ -28,6 +28,7 @@ import {
const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
const AI_DETAIL_GENERATOR_ENABLED_KEY = "ai_detail_generator_enabled";
const LOVART_COMPOSER_PREVIEW_TEXTS_KEY = "lovart_composer_preview_texts";
const LOVART_COMPOSER_PREFIX_KEY = "lovart_composer_prefix";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
@ -98,6 +99,7 @@ export async function GET() {
notificationSetting,
homeVariantSetting,
surpriseCategoriesSetting,
aiDetailGeneratorEnabledSetting,
lovartComposerPreviewTextsSetting,
lovartComposerPrefixSetting,
membershipConfigSetting,
@ -125,6 +127,12 @@ export async function GET() {
.where(eq(systemSettings.key, HOME_SURPRISE_CATEGORIES_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AI_DETAIL_GENERATOR_ENABLED_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -172,6 +180,7 @@ export async function GET() {
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
const homeVariant = normalizeHomeVariant(homeVariantSetting?.value);
const surpriseCategories = normalizeSurpriseCategories(surpriseCategoriesSetting?.value);
const aiDetailGenerationEnabled = Boolean(aiDetailGeneratorEnabledSetting?.value);
const lovartComposerPreviewTexts = normalizeLovartComposerPreviewTexts(
lovartComposerPreviewTextsSetting?.value
);
@ -221,6 +230,7 @@ export async function GET() {
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
@ -241,6 +251,7 @@ export async function GET() {
notification,
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
@ -287,6 +298,7 @@ const updateSchema = z.object({
tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
lovartPromoText: z.string().max(300).optional(),
@ -322,6 +334,7 @@ export async function PUT(request: NextRequest) {
const {
homeVariant,
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
@ -386,6 +399,25 @@ export async function PUT(request: NextRequest) {
});
}
if (aiDetailGenerationEnabled !== undefined) {
await db
.insert(systemSettings)
.values({
key: AI_DETAIL_GENERATOR_ENABLED_KEY,
value: Boolean(aiDetailGenerationEnabled),
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: Boolean(aiDetailGenerationEnabled),
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (lovartComposerPreviewTexts !== undefined) {
const normalized = normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts);
await db
@ -533,6 +565,7 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
aiDetailGenerationEnabled: aiDetailGenerationEnabled ?? false,
lovartComposerPrefix:
lovartComposerPrefix !== undefined
? normalizeLovartComposerPrefix(lovartComposerPrefix)
@ -639,6 +672,25 @@ export async function PUT(request: NextRequest) {
});
}
if (aiDetailGenerationEnabled !== undefined) {
await db
.insert(systemSettings)
.values({
key: AI_DETAIL_GENERATOR_ENABLED_KEY,
value: Boolean(aiDetailGenerationEnabled),
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: Boolean(aiDetailGenerationEnabled),
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (lovartComposerPreviewTexts !== undefined) {
const normalized = normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts);
await db
@ -786,6 +838,7 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
aiDetailGenerationEnabled: aiDetailGenerationEnabled ?? false,
lovartComposerPrefix:
lovartComposerPrefix !== undefined
? normalizeLovartComposerPrefix(lovartComposerPrefix)

File diff suppressed because it is too large Load Diff