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

View File

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

View File

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

View File

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

View File

@ -70,25 +70,30 @@ SelectScrollDownButton.displayName =
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => ( >(({ className, children, position = "popper", style, ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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" && 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", "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 className
)} )}
style={{
backgroundColor: "var(--popover)",
opacity: 1,
...style,
}}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "bg-inherit p-1",
position === "popper" && 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} {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, "when": 1772500000000,
"tag": "0045_add_canvas_image_tasks", "tag": "0045_add_canvas_image_tasks",
"breakpoints": true "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" }), .references(() => user.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull().default("AI详情项目"), title: varchar("title", { length: 256 }).notNull().default("AI详情项目"),
prompt: text("prompt").notNull().default(""), prompt: text("prompt").notNull().default(""),
generationMode: varchar("generationMode", { length: 32 })
.notNull()
.default("classic"),
brief: jsonb("brief").notNull(), brief: jsonb("brief").notNull(),
slots: jsonb("slots").notNull(), slots: jsonb("slots").notNull(),
summary: text("summary"), summary: text("summary"),

View File

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