feat: add detail generation mode pipeline and solid select popovers
This commit is contained in:
parent
0a693fdb7d
commit
dcb82c73b5
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 || '双击编辑文本'}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "CanvasDetailArchive"
|
||||||
|
ADD COLUMN IF NOT EXISTS "generationMode" varchar(32) NOT NULL DEFAULT 'classic';
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 场景裂变节点数据
|
// 场景裂变节点数据
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user