优化AI详情生成Agent流程与PC工作台体验
This commit is contained in:
parent
12704ffd58
commit
2793582fd1
@ -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
|
||||
|
||||
781
app/api/canvas/detail-plan/route.ts
Normal file
781
app/api/canvas/detail-plan/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}];
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user