feat: add detail generation mode pipeline and solid select popovers
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-24 18:06:33 +08:00
parent 0a693fdb7d
commit dcb82c73b5
9 changed files with 391 additions and 93 deletions

View File

@ -30,6 +30,12 @@ type DetailArchiveSlot = {
contentType?: string;
} | null;
type DetailGenerationMode = "classic" | "agent_layout";
function sanitizeGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function sanitizeBrief(input: unknown): DetailArchiveBrief {
const raw = (input || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
@ -136,6 +142,7 @@ export async function POST(
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const generationMode = sanitizeGenerationMode(archiveRaw.generationMode);
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
@ -147,6 +154,7 @@ export async function POST(
userId: session.user.id,
title,
prompt,
generationMode,
brief,
slots,
summary: summary || null,
@ -190,6 +198,7 @@ export async function PATCH(
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const generationMode = sanitizeGenerationMode(archiveRaw.generationMode);
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
@ -198,6 +207,7 @@ export async function PATCH(
.set({
title,
prompt,
generationMode,
brief,
slots,
summary: summary || null,

View File

@ -86,6 +86,7 @@ async function generateTextWithTimeout(params: any, timeoutMs: number) {
type DetailPlanRequest = {
prompt?: string;
count?: number;
generationMode?: string;
outputLanguage?: string;
referenceImages?: string[];
referenceImageNotes?: string[];
@ -112,6 +113,8 @@ type DetailPlanRequest = {
};
};
type DetailGenerationMode = "classic" | "agent_layout";
type DetailBrief = {
productSpu: string;
demandType: string;
@ -156,6 +159,7 @@ type DetailPlanScreen = {
};
type DetailPlanResponse = {
generationMode?: DetailGenerationMode;
summary: string;
prompts: string[];
screenSize: {
@ -786,6 +790,10 @@ function normalizeOutputLanguage(value: unknown): "zh" | "en" {
return "zh";
}
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function normalizeDetailBrief(value: unknown): DetailBrief {
const raw = (value || {}) as Record<string, unknown>;
const normalize = (key: string) => String(raw[key] || "").replace(/\s+/g, " ").trim();
@ -1127,6 +1135,7 @@ function buildFallbackScreens(params: {
function buildFallbackPlan(params: {
prompt: string;
count: number;
generationMode: DetailGenerationMode;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
@ -1154,6 +1163,7 @@ function buildFallbackPlan(params: {
coreFocus: params.coreFocus,
});
return {
generationMode: params.generationMode,
summary:
language === "en"
? `Generated ${count} storyboard frames with fixed size 790x1300.`
@ -1653,6 +1663,7 @@ export async function POST(request: Request) {
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
const hasBundleImages = bundleImageUrls.length > 0;
const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount;
const generationMode = normalizeDetailGenerationMode(body.generationMode);
const language = normalizeOutputLanguage(body.outputLanguage);
const brief = normalizeDetailBrief(body.brief);
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
@ -1668,6 +1679,7 @@ export async function POST(request: Request) {
const fallback = buildFallbackPlan({
prompt,
count,
generationMode,
language,
brief,
coreFocus,
@ -1734,6 +1746,7 @@ export async function POST(request: Request) {
buildBriefText(brief, language),
`目标分镜数量:${count}`,
`输出语言:${language === "en" ? "English" : "中文"}`,
`生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`,
"任务类型:电商详情页“AI详情生成”",
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
@ -1796,6 +1809,9 @@ export async function POST(request: Request) {
`2) 保持同一产品一致性(结构、颜色、材质、Logo、比例)。\n` +
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
(generationMode === "agent_layout"
? `4.1) 当前为AGENT排版模式:visualPrompt 只描述画面层,禁止要求模型生成标题/副标题/正文等可见文字。\n`
: "") +
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`5.1) 首屏主标题必须是“命名词+需求句”结构(6-20字,例如“甩甩杆,自拍不手抖”),并配一条结果副标题,直接回答“为什么值得买”。\n` +
`5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` +
@ -1969,6 +1985,7 @@ export async function POST(request: Request) {
const response: DetailPlanResponse = {
summary,
prompts,
generationMode,
screenSize: {
width: 790,
height: 1300,

View File

@ -749,11 +749,13 @@ type DetailSlotConfig = {
};
type DetailSectionKey = "required" | "strategy" | "assets" | "archives";
type DetailGenerationMode = "classic" | "agent_layout";
type DetailArchive = {
id: string;
title: string;
prompt: string;
generationMode: DetailGenerationMode;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
createdAt: number;
@ -764,6 +766,7 @@ type DetailArchive = {
};
type DetailPlanResponse = {
generationMode?: DetailGenerationMode;
summary?: string;
prompts?: string[];
screenSize?: {
@ -827,6 +830,7 @@ type DetailPlanResponse = {
providerType: string;
};
};
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
const DETAIL_TARGET_WIDTH = 790;
const DETAIL_TARGET_HEIGHT = 1300;
@ -880,6 +884,22 @@ const DETAIL_FIELD_EXAMPLES: Record<keyof DetailBriefForm, string> = {
brandTone: "示例:专业、可靠、亲和",
bannedWords: "示例:最强、第一、100%治愈",
};
const DETAIL_GENERATION_MODE_OPTIONS: Array<{
value: DetailGenerationMode;
label: string;
description: string;
}> = [
{
value: "classic",
label: "传统生图",
description: "整页直接生图,速度更快",
},
{
value: "agent_layout",
label: "AGENT排版",
description: "生图只出画面,文字层可编辑",
},
];
const SESSION_TITLE_MAX_LEN = 18;
@ -936,6 +956,24 @@ type DetailScreenReviewResult = {
} | null;
};
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function extractFirstNumber(value: string, fallback: number): number {
const matched = String(value || "").match(/(\d{1,3})/);
if (!matched) return fallback;
const parsed = Number(matched[1]);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
function extractFontWeight(value: string, fallback = "600"): string {
const matched = String(value || "").match(/(?:\/|weight[::]?\s*)(\d{3})/i);
if (!matched) return fallback;
return matched[1] || fallback;
}
function splitDetailKeywords(value: string, limit = 3): string[] {
const raw = String(value || "")
.split(/[\n,,、;;|]/)
@ -1028,6 +1066,7 @@ function buildDetailReviewPlans(params: {
}
function buildDetailResultMarkdown(params: {
generationMode?: DetailGenerationMode;
summary?: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
prompts: string[];
@ -1077,6 +1116,11 @@ function buildDetailResultMarkdown(params: {
}): string {
const lines: string[] = [];
lines.push("### AI详情生成完成");
if (params.generationMode) {
lines.push(
`生成路线:${params.generationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页生图)"}`
);
}
if (params.summary) {
lines.push(params.summary);
}
@ -1200,6 +1244,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
const [detailGenerationMode, setDetailGenerationMode] = useState<DetailGenerationMode>("classic");
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
required: true,
@ -2247,6 +2292,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (!selectedModelId && siteSettings) {
// 使用系统设置的默认模型,如果没有设置则使用Agent模式
const defaultModel = siteSettings.canvasDefaultModelId || "auto";
currentModelIdRef.current = defaultModel;
setSelectedModelId(defaultModel);
}
}, [selectedModelId, siteSettings]);
@ -2259,6 +2305,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
useEffect(() => {
if (!modelSelectValue || modelSelectValue === selectedModelId) return;
currentModelIdRef.current = modelSelectValue;
setSelectedModelId(modelSelectValue);
}, [modelSelectValue, selectedModelId]);
@ -2332,6 +2379,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const handleSelectGenerationMode = useCallback(
(mode: "agent" | "image" | "video" | "detail") => {
if (mode === "agent") {
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
setIsDetailGenerationMode(false);
return;
@ -2342,6 +2390,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
toast.error("AI详情生成功能未开启");
return;
}
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
setIsDetailGenerationMode(true);
setIsDetailWorkbenchCollapsed(true);
@ -2361,6 +2410,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
toast.error("当前没有可用的图像模型");
return;
}
currentModelIdRef.current = preferredImageModelId;
setSelectedModelId(preferredImageModelId);
setIsDetailGenerationMode(false);
return;
@ -2375,6 +2425,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
toast.error("当前没有可用的视频模型");
return;
}
currentModelIdRef.current = preferredVideoModelId;
setSelectedModelId(preferredVideoModelId);
setIsDetailGenerationMode(false);
},
@ -2468,6 +2519,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
id: String(item.id),
title: String(item.title || "未命名详情项目"),
prompt: String(item.prompt || ""),
generationMode: normalizeDetailGenerationMode((item as any).generationMode),
brief: {
productSpu: String(item.brief?.productSpu || ""),
demandType: String(item.brief?.demandType || ""),
@ -2697,6 +2749,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
parts.push({ type: "text" as const, text: textContent });
}
// 强制同步当前模型,避免模型切换后瞬时发送命中旧模型
currentModelIdRef.current = selectedModelId;
sendMessage({
role: "user",
parts,
@ -3383,6 +3438,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageCount?: number;
coverUrl?: string;
prompt: string;
generationMode: DetailGenerationMode;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
}): Promise<DetailArchive | null> => {
@ -3404,6 +3460,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
id: archiveId,
title: archiveTitle,
prompt: promptText,
generationMode: params.generationMode,
brief: { ...params.brief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = params.slots[index];
@ -3438,6 +3495,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const loadDetailArchive = useCallback((archive: DetailArchive) => {
setIsDetailGenerationMode(true);
setDetailGenerationMode(normalizeDetailGenerationMode(archive.generationMode));
setDetailBrief({ ...archive.brief });
setDetailSlots(
DETAIL_SLOT_CONFIGS.map((_, index) => {
@ -3539,6 +3597,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`;
const archiveSnapshot = {
prompt: detailPrompt,
generationMode: detailGenerationMode,
brief: { ...detailBrief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = detailSlots[index];
@ -3555,6 +3614,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const assistantMessageId = generateUUID();
const skillLogicPreview = [
"### AI详情生成",
detailGenerationMode === "agent_layout"
? "当前模式:AGENT排版(画面与文字分层生成)"
: "当前模式:传统生图(整页直接生成)",
"我们将基于《详情升级文案方案》执行以下技能流程:",
"1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果",
"2. 需求拆解:卖点转买点,确定核心需求优先级",
@ -3599,6 +3661,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
modelImageHint,
bundleImageHint,
`输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`,
`生成模式:${detailGenerationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页出图)"}`,
`字体规范:${DETAIL_TYPOGRAPHY_LOCK.title};${DETAIL_TYPOGRAPHY_LOCK.subtitle};${DETAIL_TYPOGRAPHY_LOCK.body}`,
]
.filter(Boolean)
@ -3721,6 +3784,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageCount: 0,
coverUrl: undefined,
prompt: archiveSnapshot.prompt,
generationMode: archiveSnapshot.generationMode,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
@ -3776,6 +3840,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
body: JSON.stringify({
prompt: detailPrompt,
count: detailCount,
generationMode: detailGenerationMode,
referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 12),
referenceImageNotes,
modelImageUrl: modelAttachment.url,
@ -3791,6 +3856,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
const planData = (planResult.data || {}) as DetailPlanResponse;
const activeDetailGenerationMode = normalizeDetailGenerationMode(
planData.generationMode || detailGenerationMode
);
const perScreenSteps = activeDetailGenerationMode === "agent_layout" ? 1 : 2;
const prompts = Array.isArray(planData.prompts)
? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean)
: [];
@ -3804,7 +3873,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
language: outputLanguage,
count: DETAIL_REVIEW_COUNT,
});
const totalSteps = prompts.length * 2 + 1 + reviewPlans.length;
const totalSteps = prompts.length * perScreenSteps + 1 + reviewPlans.length;
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
? planData.qualityChecks?.issues.filter(Boolean)
: [];
@ -3837,6 +3906,110 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (detailPlaceholderNodeIds.length > prompts.length) {
markExtraPlaceholders(prompts.length, "本轮未使用");
}
const isAgentLayoutMode = activeDetailGenerationMode === "agent_layout";
const titleFontSize = Math.max(
36,
Math.min(
80,
extractFirstNumber(planData.globalStyle?.typography?.title || "", 64)
)
);
const subtitleFontSize = Math.max(
24,
Math.min(
50,
extractFirstNumber(planData.globalStyle?.typography?.subtitle || "", 34)
)
);
const bodyFontSize = Math.max(
18,
Math.min(
36,
extractFirstNumber(planData.globalStyle?.typography?.body || "", 24)
)
);
const titleFontWeight = extractFontWeight(planData.globalStyle?.typography?.title || "", "700");
const subtitleFontWeight = extractFontWeight(
planData.globalStyle?.typography?.subtitle || "",
"600"
);
const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500");
const createAgentLayoutTextLayers = (params: {
nodeId: string;
screenIndex: number;
screen?: DetailPlanScreenSpec;
}) => {
if (!isAgentLayoutMode) return;
const storeState = useCanvasStore.getState();
const imageNode = storeState.getNode(params.nodeId);
if (!imageNode) return;
const x = Number(imageNode.position?.x || 0);
const y = Number(imageNode.position?.y || 0);
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
const safeLeft = Math.round(width * 0.07);
const textWidth = Math.max(240, Math.round(width - safeLeft * 2));
const screen = params.screen;
const titleText = String(screen?.title || "").trim();
const subtitleText = String(screen?.subtitle || "").trim();
const bodyText = String(screen?.copy || "").trim();
if (titleText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 72 },
size: { width: textWidth, height: 140 },
data: {
content: titleText,
fontSize: params.screenIndex === 0 ? titleFontSize : Math.round(titleFontSize * 0.86),
fontWeight: titleFontWeight,
color: "#111827",
lineHeight: 1.15,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
if (subtitleText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 214 },
size: { width: textWidth, height: 120 },
data: {
content: subtitleText,
fontSize: subtitleFontSize,
fontWeight: subtitleFontWeight,
color: "#374151",
lineHeight: 1.22,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
if (bodyText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 304 },
size: { width: textWidth, height: 260 },
data: {
content: bodyText,
fontSize: bodyFontSize,
fontWeight: bodyFontWeight,
color: "#4b5563",
lineHeight: 1.38,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
};
for (let index = 0; index < prompts.length; index += 1) {
const screen = screenSpecs[index];
patchDetailPlaceholder(index, {
@ -3852,6 +4025,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
detailPlanSummary: planData.summary || "",
detailGlobalStyle: planData.globalStyle || null,
detailPreflight: planData.preflight || null,
detailGenerationMode: activeDetailGenerationMode,
detailGeneratingPlaceholder: true,
},
});
@ -3878,7 +4052,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const bundleReason = String((screen as any)?.bundleReason || "").trim();
const promptText =
String(screen?.visualPrompt || "").trim() || prompts[index]!;
const generationStepCurrent = 2 + index * 2;
const generationStepCurrent = 2 + index * perScreenSteps;
const reviewStepCurrent = generationStepCurrent + 1;
patchDetailPlaceholder(index, {
label: `主图首稿生成中 ${index + 1}/${prompts.length}`,
@ -3922,7 +4096,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
parts: [
{
type: "text",
text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`,
text: isAgentLayoutMode
? `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏画面层,随后自动渲染可编辑文字层...`
: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`,
},
],
}
@ -3947,16 +4123,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。"
: "",
bundleReason ? `搭配依据:${bundleReason}` : "",
screen?.title ? `本屏标题:${screen.title}` : "",
screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
screen?.copy ? `本屏文案说明:${screen.copy}` : "",
isAgentLayoutMode
? "生成策略:仅生成画面层(产品、光影、材质、场景),不要生成任何文字、数字、Logo、标语、水印。"
: screen?.title
? `本屏标题:${screen.title}`
: "",
isAgentLayoutMode ? "" : screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
isAgentLayoutMode ? "" : screen?.copy ? `本屏文案说明:${screen.copy}` : "",
screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "",
screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "",
screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "",
screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "",
screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "",
`输出为竖版电商详情图,尺寸固定 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素。`,
"要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。",
isAgentLayoutMode
? "硬性约束:禁止输出任何可见文字或字符,排版文字由后续程序化图层渲染。"
: "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。",
]
.filter(Boolean)
.join("\n\n");
@ -3999,75 +4181,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
let finalGenerated = firstPassImage;
let finalGenerationPrompt = generationPrompt;
let screenReviewResult: DetailScreenReviewResult | null = null;
patchDetailPlaceholder(index, {
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
status: "generating",
});
setDetailProgress({
status: "running",
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
current: reviewStepCurrent,
total: totalSteps,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
},
],
}
: message,
),
);
try {
const reviewResponse = await fetch("/api/canvas/detail-review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outputLanguage,
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
referenceImages: generationReferenceImagesBase.slice(0, 6),
brief,
globalStyle: planData.globalStyle,
screen: {
index: index + 1,
title: screen?.title || "",
subtitle: screen?.subtitle || "",
copy: screen?.copy || "",
layoutNote: screen?.layoutNote || "",
visualPrompt: generationPrompt,
screenType: screen?.screenType || "",
},
}),
});
const reviewResult = await reviewResponse.json().catch(() => ({}));
if (reviewResponse.ok && reviewResult?.success) {
screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult;
}
} catch (reviewError) {
console.error("[CanvasChat] Detail screen review failed:", reviewError);
}
const shouldRegenerate =
screenReviewResult?.pass === false &&
typeof screenReviewResult?.revisedPrompt === "string" &&
screenReviewResult.revisedPrompt.trim().length > 20;
if (shouldRegenerate) {
if (!isAgentLayoutMode) {
patchDetailPlaceholder(index, {
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
status: "generating",
});
const issueHint = Array.isArray(screenReviewResult?.issues)
? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";")
: "";
setDetailProgress({
status: "running",
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
current: reviewStepCurrent,
total: totalSteps,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
@ -4076,7 +4201,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`,
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
},
],
}
@ -4084,21 +4209,85 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
),
);
for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) {
try {
const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim();
const regenerated = await generateDetailFrame(revisedPrompt, [
firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
]);
finalGenerated = regenerated;
finalGenerationPrompt = revisedPrompt;
break;
} catch (regenError) {
if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) {
console.error("[CanvasChat] Detail second-pass generation failed:", regenError);
try {
const reviewResponse = await fetch("/api/canvas/detail-review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outputLanguage,
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
referenceImages: generationReferenceImagesBase.slice(0, 6),
brief,
globalStyle: planData.globalStyle,
screen: {
index: index + 1,
title: screen?.title || "",
subtitle: screen?.subtitle || "",
copy: screen?.copy || "",
layoutNote: screen?.layoutNote || "",
visualPrompt: generationPrompt,
screenType: screen?.screenType || "",
},
}),
});
const reviewResult = await reviewResponse.json().catch(() => ({}));
if (reviewResponse.ok && reviewResult?.success) {
screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult;
}
} catch (reviewError) {
console.error("[CanvasChat] Detail screen review failed:", reviewError);
}
const shouldRegenerate =
screenReviewResult?.pass === false &&
typeof screenReviewResult?.revisedPrompt === "string" &&
screenReviewResult.revisedPrompt.trim().length > 20;
if (shouldRegenerate) {
patchDetailPlaceholder(index, {
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
status: "generating",
});
const issueHint = Array.isArray(screenReviewResult?.issues)
? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";")
: "";
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`,
},
],
}
: message,
),
);
for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) {
try {
const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim();
const regenerated = await generateDetailFrame(revisedPrompt, [
firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
]);
finalGenerated = regenerated;
finalGenerationPrompt = revisedPrompt;
break;
} catch (regenError) {
if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) {
console.error("[CanvasChat] Detail second-pass generation failed:", regenError);
}
}
}
}
} else {
patchDetailPlaceholder(index, {
label: `排版层渲染中 ${index + 1}/${prompts.length}`,
status: "generating",
});
}
generatedImages.push(finalGenerated);
@ -4159,12 +4348,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
detailBundleShowcase: bundleShowcase,
detailBundleReason: bundleReason,
detailGlobalStyle: planData.globalStyle || null,
detailGenerationMode: activeDetailGenerationMode,
detailScreenReview: screenReviewResult || null,
detailGeneratingPlaceholder: false,
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
},
},
});
createAgentLayoutTextLayers({
nodeId: targetNodeId,
screenIndex: index,
screen,
});
}
}
if (generatedImages.length !== prompts.length) {
@ -4289,6 +4484,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
const finalMarkdown = buildDetailResultMarkdown({
generationMode: activeDetailGenerationMode,
summary: planData.summary,
images: generatedImages,
prompts,
@ -4347,6 +4543,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
prompt: archiveSnapshot.prompt,
generationMode: archiveSnapshot.generationMode,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
@ -4429,6 +4626,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
addNode,
clearSelection,
detailBrief,
detailGenerationMode,
detailSlots,
imageParams.negativePrompt,
input,
@ -4764,7 +4962,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
if (!input.trim() && attachments.length === 0) return;
if (!selectedModelId) {
const effectiveModelId = currentModelIdRef.current || selectedModelId;
if (!effectiveModelId) {
toast.error("请选择一个模型");
return;
}
@ -4883,6 +5082,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
parts.push({ type: "text" as const, text: textContent });
}
// 若 ref 为空才回填,避免覆盖“刚切换但状态尚未刷新的新模型值”
if (!currentModelIdRef.current) {
currentModelIdRef.current = effectiveModelId;
}
// 使用与 multimodal-input 相同的格式发送消息
sendMessage({
role: "user",
@ -6226,6 +6430,43 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
)}
</div>
<div className="mt-3 rounded-xl border border-border/50 bg-zinc-50/40 p-3 dark:bg-zinc-900/40">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium text-foreground">生成路线</p>
<span className="text-[10px] text-muted-foreground">
{detailGenerationMode === "classic" ? "当前:传统生图" : "当前:AGENT排版"}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{DETAIL_GENERATION_MODE_OPTIONS.map((option) => {
const selected = detailGenerationMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
if (isDetailGenerating) return;
setDetailGenerationMode(option.value);
}}
disabled={isDetailGenerating}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors",
isDetailGenerating && "cursor-not-allowed opacity-60",
selected
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 bg-background text-muted-foreground hover:border-primary/30 hover:text-foreground"
)}
>
<div className="text-[11px] font-medium">{option.label}</div>
<div className="mt-1 text-[10px] leading-snug opacity-80">
{option.description}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-4 space-y-3">
{/* Required Info */}
<Collapsible
@ -6669,6 +6910,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<span>{new Date(archive.createdAt).toLocaleString()}</span>
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span>{archive.imageCount || 0}张输出</span>
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span>{archive.generationMode === "agent_layout" ? "AGENT排版" : "传统生图"}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
@ -7127,6 +7370,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<Select
value={modelSelectValue}
onValueChange={(value) => {
currentModelIdRef.current = value;
setSelectedModelId(value);
if (value !== "auto" || !aiDetailGenerationEnabled) {
setIsDetailGenerationMode(false);

View File

@ -49,8 +49,11 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
return (
<div
className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm p-3",
isSelected && "border-primary"
"relative w-full h-full overflow-hidden p-3",
data.transparent
? "rounded-none border-0 bg-transparent p-0 shadow-none"
: "rounded-lg border bg-card shadow-sm",
isSelected && (data.transparent ? "ring-1 ring-primary/50" : "border-primary")
)}
onDoubleClick={handleDoubleClick}
>
@ -73,6 +76,8 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
lineHeight: data.lineHeight || 1.35,
textAlign: data.textAlign || "left",
}}
/>
) : (
@ -82,6 +87,8 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
lineHeight: data.lineHeight || 1.35,
textAlign: data.textAlign || "left",
}}
>
{data.content || '双击编辑文本'}

View File

@ -70,25 +70,30 @@ SelectScrollDownButton.displayName =
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = "popper", style, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[10000] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-[10000] max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
style={{
backgroundColor: "var(--popover)",
opacity: 1,
...style,
}}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
"bg-inherit p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}

View File

@ -0,0 +1,2 @@
ALTER TABLE "CanvasDetailArchive"
ADD COLUMN IF NOT EXISTS "generationMode" varchar(32) NOT NULL DEFAULT 'classic';

View File

@ -225,6 +225,13 @@
"when": 1772500000000,
"tag": "0045_add_canvas_image_tasks",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1772517600000,
"tag": "0046_add_canvas_detail_generation_mode",
"breakpoints": true
}
]
}

View File

@ -510,6 +510,9 @@ export const canvasDetailArchive = pgTable("CanvasDetailArchive", {
.references(() => user.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull().default("AI详情项目"),
prompt: text("prompt").notNull().default(""),
generationMode: varchar("generationMode", { length: 32 })
.notNull()
.default("classic"),
brief: jsonb("brief").notNull(),
slots: jsonb("slots").notNull(),
summary: text("summary"),

View File

@ -74,6 +74,9 @@ export interface TextNodeData {
fontSize?: number;
color?: string;
fontWeight?: string;
lineHeight?: number;
textAlign?: "left" | "center" | "right";
transparent?: boolean;
}
// 场景裂变节点数据