fix: improve detail planning and realtime canvas behavior
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-22 04:52:12 +08:00
parent 4eabe6e583
commit 071958bb15
8 changed files with 797 additions and 58 deletions

View File

@ -224,3 +224,53 @@ export async function PATCH(
return NextResponse.json({ success: false, error: "更新详情存档失败" }, { status: 500 }); return NextResponse.json({ success: false, error: "更新详情存档失败" }, { status: 500 });
} }
} }
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id: projectId } = await params;
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
if (!ownsProject) {
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
}
const searchArchiveId = request.nextUrl.searchParams.get("archiveId");
let bodyArchiveId = "";
if (!searchArchiveId) {
const body = await request.json().catch(() => ({}));
bodyArchiveId = String((body as any)?.archiveId || "").trim();
}
const archiveId = String(searchArchiveId || bodyArchiveId || "").trim();
if (!archiveId) {
return NextResponse.json({ success: false, error: "缺少存档ID" }, { status: 400 });
}
const [deleted] = await db
.delete(canvasDetailArchive)
.where(
and(
eq(canvasDetailArchive.id, archiveId),
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.returning();
if (!deleted) {
return NextResponse.json({ success: false, error: "存档不存在或无权限" }, { status: 404 });
}
return NextResponse.json({ success: true, data: deleted });
} catch (error) {
console.error("[CanvasDetailArchive] DELETE failed:", error);
return NextResponse.json({ success: false, error: "删除详情存档失败" }, { status: 500 });
}
}

View File

@ -170,6 +170,14 @@ type DetailPlanPreflight = {
rewriteApplied: boolean; rewriteApplied: boolean;
}; };
type DetailCoreFocusContext = {
anchor: string;
primaryNeed: string;
firstScreenTitle: string;
firstScreenSubtitle: string;
focusTerms: string[];
};
const AGENT_PLANNING_PHRASE_PATTERNS = [ const AGENT_PLANNING_PHRASE_PATTERNS = [
/第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
/本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
@ -249,6 +257,294 @@ function visibleTitleLength(value: string): number {
.trim().length; .trim().length;
} }
function splitToCoreTerms(value: string, language: "zh" | "en"): string[] {
const raw = String(value || "").trim();
if (!raw) return [];
if (language === "en") {
return Array.from(
new Set(
raw
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((term) => term.trim())
.filter((term) => term.length >= 3)
.slice(0, 6)
)
);
}
const compact = raw.replace(/\s+/g, "");
const clauses = compact
.split(/[,。;、::,.!?!?/|()()\-—]+/g)
.map((item) => item.trim())
.filter(Boolean);
const seed = clauses[0] || compact;
const terms = new Set<string>();
if (seed.length >= 2) terms.add(seed.slice(0, Math.min(6, seed.length)));
if (seed.length >= 4) terms.add(seed.slice(0, 4));
terms.add(seed);
return Array.from(terms).filter((term) => term.length >= 2).slice(0, 6);
}
function compactZhTitle(value: string): string {
const base = String(value || "")
.replace(/[“”"'`]/g, "")
.replace(/[,。;、::,.!?!?/|()()\-—\s]+/g, "")
.trim();
if (!base) return "核心不妥协";
if (base.length > 10) return base.slice(0, 10);
if (base.length < 4) return `${base}升级`;
return base;
}
function compactEnTitle(value: string): string {
const words = String(value || "")
.replace(/[“”"'`]/g, "")
.replace(/\s+/g, " ")
.trim()
.split(/[^a-z0-9]+/i)
.map((word) => word.trim())
.filter(Boolean);
if (words.length === 0) return "Core Upgrade";
return words.slice(0, 4).join(" ");
}
function inferZhProductSuffix(productName: string): string {
const name = String(productName || "").replace(/\s+/g, "");
if (!name) return "";
const rules: Array<{ pattern: RegExp; suffix: string }> = [
{ pattern: /自拍杆|稳定杆|补光杆/, suffix: "杆" },
{ pattern: /尿不湿|纸尿裤|拉拉裤|尿裤/, suffix: "裤" },
{ pattern: /面膜/, suffix: "膜" },
{ pattern: /精华|精华液/, suffix: "精华" },
{ pattern: /洗发水|洗发露/, suffix: "露" },
{ pattern: /沐浴露/, suffix: "露" },
{ pattern: /牙膏/, suffix: "膏" },
{ pattern: /椅子|座椅|办公椅|人体工学椅/, suffix: "椅" },
{ pattern: /枕头|枕/, suffix: "枕" },
{ pattern: /书桌|桌子|电脑桌/, suffix: "桌" },
{ pattern: /收纳柜|收纳架|收纳箱/, suffix: "架" },
];
const matched = rules.find((rule) => rule.pattern.test(name));
if (matched) return matched.suffix;
const tailMatch = name.match(/[杆裤膜露膏椅枕桌架杯包箱机器锅刷]/g);
if (tailMatch && tailMatch.length > 0) {
return tailMatch[tailMatch.length - 1] || "";
}
return "";
}
function inferZhNamingStem(anchor: string, primaryNeed: string): string {
const source = `${anchor} ${primaryNeed}`.replace(/\s+/g, "");
const rules: Array<{ pattern: RegExp; stem: string }> = [
{ pattern: /抗过敏|防过敏|敏感|低敏|温和/, stem: "安敏" },
{ pattern: /甩|一甩|快速展开/, stem: "甩甩" },
{ pattern: /防抖|稳定|稳拍/, stem: "稳拍" },
{ pattern: /轻|便携|小巧/, stem: "轻享" },
{ pattern: /护腰|贴腰|腰撑/, stem: "护腰" },
{ pattern: /透气|呼吸感/, stem: "透气" },
{ pattern: /省空间|收纳/, stem: "省位" },
{ pattern: /抗菌|抑菌|洁净/, stem: "净护" },
];
const matched = rules.find((rule) => rule.pattern.test(source));
if (matched) return matched.stem;
const compact = compactZhTitle(anchor);
if (!compact) return "安心";
if (compact.length === 1) return `${compact}${compact}`;
return compact.slice(0, Math.min(4, compact.length));
}
const ZH_NAME_BANNED_WORDS = [
"核心",
"价值",
"方案",
"系统",
"智能",
"科技",
"场景",
"升级",
"高端",
"专业",
];
function toColloquialZhStem(stem: string): string {
let next = String(stem || "").replace(/\s+/g, "");
if (!next) return "好用";
const map: Array<{ pattern: RegExp; value: string }> = [
{ pattern: /快速|秒开|一秒|展开|甩/, value: "甩甩" },
{ pattern: /防抖|稳定|稳拍|不晃/, value: "稳稳" },
{ pattern: /抗过敏|安敏|低敏|敏感/, value: "安敏" },
{ pattern: /透气|不闷|呼吸感/, value: "透透" },
{ pattern: /防漏/, value: "防漏" },
{ pattern: /护腰|贴腰|腰撑/, value: "护腰" },
{ pattern: /省空间|收纳|整洁/, value: "省位" },
{ pattern: /轻便|小巧|便携/, value: "轻轻" },
];
const hit = map.find((item) => item.pattern.test(next));
if (hit) {
next = hit.value;
}
ZH_NAME_BANNED_WORDS.forEach((token) => {
next = next.replace(new RegExp(token, "g"), "");
});
next = next.replace(/[,。;、::,.!?!?/|()()\-—]/g, "");
if (!next) return "好用";
if (next.length === 1) return `${next}${next}`;
if (next.length > 4) return next.slice(0, 4);
return next;
}
function buildZhCoreNamingTitle(brief: DetailBrief, anchor: string, primaryNeed: string): string {
const suffix = inferZhProductSuffix(brief.productName);
let stem = toColloquialZhStem(inferZhNamingStem(anchor, primaryNeed));
if (!suffix) return compactZhTitle(anchor);
if (stem.length > 4) {
stem = stem.slice(0, 4);
}
if (stem.length === 1) {
stem = `${stem}${stem}`;
}
let title = stem.endsWith(suffix) ? stem : `${stem}${suffix}`;
if (title.length < 3) {
title = `${compactZhTitle(anchor).slice(0, 2)}${suffix}`;
}
if (title.length > 10) {
title = title.slice(0, 10);
}
return title;
}
function buildCoreFocusContext(brief: DetailBrief, language: "zh" | "en"): DetailCoreFocusContext {
const anchorRaw =
brief.coreSellingPoint ||
brief.positioningStatement ||
brief.coreNeed ||
(language === "en" ? "core value" : "核心卖点");
const scenario = brief.usageScenario || (language === "en" ? "daily use" : "日常场景");
const primaryNeed = brief.coreNeed || (language === "en" ? "key demand" : "核心需求");
const riskText = brief.riskConcern || (language === "en" ? "extra effort" : "反复折腾");
const expectedText = brief.expectedResult || primaryNeed;
const focusTerms = Array.from(
new Set([
...splitToCoreTerms(anchorRaw, language),
...splitToCoreTerms(primaryNeed, language),
...splitToCoreTerms(expectedText, language),
])
).slice(0, 8);
if (language === "en") {
const title = compactEnTitle(anchorRaw);
const subtitle = `In ${scenario}, achieve ${expectedText} without ${riskText}.`;
return {
anchor: anchorRaw,
primaryNeed,
firstScreenTitle: title,
firstScreenSubtitle: subtitle,
focusTerms,
};
}
const title = buildZhCoreNamingTitle(brief, anchorRaw, primaryNeed);
const subtitle = `在${scenario}场景,不用${riskText}也能${expectedText}。`;
return {
anchor: anchorRaw,
primaryNeed,
firstScreenTitle: title,
firstScreenSubtitle: subtitle,
focusTerms: Array.from(
new Set([...focusTerms, ...splitToCoreTerms(title, language)])
),
};
}
function containsCoreTerms(text: string, terms: string[]): boolean {
const source = String(text || "").toLowerCase();
if (!source || terms.length === 0) return false;
return terms.some((term) => source.includes(String(term || "").toLowerCase()));
}
function enforceCoreFocusOnScreens(params: {
screens: DetailPlanScreen[];
coreFocus: DetailCoreFocusContext;
language: "zh" | "en";
}): DetailPlanScreen[] {
const { screens, coreFocus, language } = params;
const namingWord = coreFocus.firstScreenTitle;
return screens.map((screen, index) => {
const mergedText = `${screen.title} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`;
const focused = containsCoreTerms(mergedText, coreFocus.focusTerms);
const promptCoreLine =
language === "en"
? `Core selling-point anchor: ${coreFocus.anchor}. This frame must serve this same anchor and avoid topic drift.`
: `核心卖点锚点:${coreFocus.anchor}。本屏必须服务同一核心卖点,不得偏题。`;
let title = screen.title;
let subtitle = screen.subtitle;
let objective = screen.objective;
let copy = screen.copy;
let benefit = screen.fabe.b;
if (index === 0) {
title =
language === "zh"
? coreFocus.firstScreenTitle
: hasPlanningTitleMeta(title) || !containsCoreTerms(title, coreFocus.focusTerms)
? coreFocus.firstScreenTitle
: title;
subtitle = containsCoreTerms(`${subtitle} ${copy}`, coreFocus.focusTerms)
? subtitle
: coreFocus.firstScreenSubtitle;
}
if (!containsCoreTerms(objective, coreFocus.focusTerms)) {
objective =
language === "en"
? `${objective}. Keep reinforcing "${coreFocus.anchor}".`
: `${objective},持续强化“${coreFocus.anchor}”。`;
}
if (!focused) {
copy =
language === "en"
? `${copy} Keep all messaging aligned to "${coreFocus.anchor}" and "${coreFocus.primaryNeed}".`
: `${copy} 全屏文案围绕“${coreFocus.anchor}”,统一服务“${coreFocus.primaryNeed}”。`;
benefit =
language === "en"
? `Deliver "${coreFocus.primaryNeed}" under "${coreFocus.anchor}" with lower effort and clearer payoff.`
: `在“${coreFocus.anchor}”下交付“${coreFocus.primaryNeed}”结果,降低决策与使用成本。`;
}
if (
language === "zh" &&
index > 0 &&
index <= 3 &&
namingWord &&
!String(`${subtitle} ${copy}`).includes(namingWord)
) {
copy = `${copy} 持续强化“${namingWord}”这一记忆点,形成独有搜索词。`;
}
return {
...screen,
title,
subtitle,
objective,
copy,
fabe: {
...screen.fabe,
b: benefit,
},
visualPrompt: screen.visualPrompt.includes(coreFocus.anchor)
? screen.visualPrompt
: `${screen.visualPrompt} ${promptCoreLine}`,
};
});
}
function buildTypographySpec(language: "zh" | "en") { function buildTypographySpec(language: "zh" | "en") {
if (language === "en") { if (language === "en") {
return { return {
@ -467,6 +763,7 @@ function buildFallbackScreens(params: {
count: number; count: number;
language: "zh" | "en"; language: "zh" | "en";
brief: DetailBrief; brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
globalStyle: DetailPlanResponse["globalStyle"]; globalStyle: DetailPlanResponse["globalStyle"];
referenceImageNotes: string[]; referenceImageNotes: string[];
hasModelImage: boolean; hasModelImage: boolean;
@ -478,6 +775,7 @@ function buildFallbackScreens(params: {
count, count,
language, language,
brief, brief,
coreFocus,
globalStyle, globalStyle,
referenceImageNotes, referenceImageNotes,
hasModelImage, hasModelImage,
@ -510,8 +808,20 @@ function buildFallbackScreens(params: {
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"]; const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
const defaultTitles = const defaultTitles =
language === "en" language === "en"
? ["Long-Sit Comfort", "Waist Support", "One-Step Adjust", "Stable Structure", "Verified Trust"] ? [
: ["久坐不累", "贴腰承托", "一键调节", "稳固支撑", "安心可证"]; coreFocus.firstScreenTitle,
"Core Benefit Proof",
"Mechanism Breakdown",
"Difference Reinforcement",
"Trust Evidence",
]
: [
coreFocus.firstScreenTitle,
"核心收益可证",
"机制拆解",
"差异强化",
"证据收口",
];
return Array.from({ length: count }).map((_, index) => { return Array.from({ length: count }).map((_, index) => {
const screenType = const screenType =
@ -535,17 +845,19 @@ function buildFallbackScreens(params: {
? language === "en" ? language === "en"
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.` ? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。` : `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
: index === 0
? coreFocus.firstScreenSubtitle
: language === "en" : language === "en"
? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.` ? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
: `在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`; : `围绕“${coreFocus.anchor}”,在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
const copy = const copy =
isBundleScreen isBundleScreen
? language === "en" ? language === "en"
? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.` ? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.`
: `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。` : `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。`
: language === "en" : language === "en"
? `${productLabel} turns key pain points into visible outcomes with a clear decision path.` ? `${productLabel} turns key pain points into visible outcomes under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.`
: `${productLabel}将关键痛点转为可感知结果,形成清晰购买决策路径。`; : `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`;
const layoutNote = const layoutNote =
isBundleScreen isBundleScreen
? language === "en" ? language === "en"
@ -593,6 +905,7 @@ function buildFallbackScreens(params: {
`Keep exact product identity consistent across all frames.`, `Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; core demand: ${coreNeed}.`, `Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
"Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.", "Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.",
`Core selling-point anchor: ${coreFocus.anchor}; this frame must stay aligned to this anchor.`,
`Positioning statement: ${positioning}.`, `Positioning statement: ${positioning}.`,
`Global style: ${globalStyle.designLanguage}.`, `Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`, `Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
@ -616,6 +929,7 @@ function buildFallbackScreens(params: {
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。", "全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};核心需求:${coreNeed}。`, `主场景:${mainScenario};核心需求:${coreNeed}。`,
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。", "人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
`核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`,
`一句话定位:${positioning}。`, `一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`, `全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`, `字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
@ -655,6 +969,7 @@ function buildFallbackPlan(params: {
count: number; count: number;
language: "zh" | "en"; language: "zh" | "en";
brief: DetailBrief; brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
referenceImageNotes: string[]; referenceImageNotes: string[];
hasModelImage: boolean; hasModelImage: boolean;
hasBundleImages: boolean; hasBundleImages: boolean;
@ -663,14 +978,20 @@ function buildFallbackPlan(params: {
const { count, language } = params; const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language); const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = buildDetailSkillFlow(language); const skillFlow = buildDetailSkillFlow(language);
const screens = buildFallbackScreens({ const fallbackScreens = buildFallbackScreens({
...params, ...params,
globalStyle, globalStyle,
}); });
const screens = enforceCoreFocusOnScreens({
screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
});
const qualityChecks = evaluatePlanQuality({ const qualityChecks = evaluatePlanQuality({
screens, screens,
hasModelImage: params.hasModelImage, hasModelImage: params.hasModelImage,
hasBundleImages: params.hasBundleImages, hasBundleImages: params.hasBundleImages,
coreFocus: params.coreFocus,
}); });
return { return {
summary: summary:
@ -810,8 +1131,9 @@ function evaluatePlanQuality(params: {
screens: DetailPlanScreen[]; screens: DetailPlanScreen[];
hasModelImage: boolean; hasModelImage: boolean;
hasBundleImages: boolean; hasBundleImages: boolean;
coreFocus?: DetailCoreFocusContext;
}): { passed: boolean; issues: string[] } { }): { passed: boolean; issues: string[] } {
const { screens, hasModelImage, hasBundleImages } = params; const { screens, hasModelImage, hasBundleImages, coreFocus } = params;
const issues: string[] = []; const issues: string[] = [];
if (screens.length === 0) { if (screens.length === 0) {
issues.push("未生成任何分镜。"); issues.push("未生成任何分镜。");
@ -862,12 +1184,13 @@ function evaluatePlanQuality(params: {
issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。"); issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。");
} }
const titleLengthOutOfRange = screens.some((screen) => { const titleLengthOutOfRange = screens.some((screen, index) => {
const len = visibleTitleLength(screen.title); const len = visibleTitleLength(screen.title);
return len < 4 || len > 10; const minLen = index === 0 ? 3 : 4;
return len < minLen || len > 10;
}); });
if (titleLengthOutOfRange) { if (titleLengthOutOfRange) {
issues.push("存在分镜主标题不在4-10字区间。"); issues.push("存在分镜主标题长度不符合规范(首屏3-10字,其余4-10字)。");
} }
const subtitleTooLong = screens.some((screen) => { const subtitleTooLong = screens.some((screen) => {
@ -886,6 +1209,33 @@ function evaluatePlanQuality(params: {
issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。"); issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。");
} }
if (coreFocus && coreFocus.focusTerms.length > 0 && screens.length > 0) {
const firstScreen = screens[0];
const firstScreenText = `${firstScreen?.title || ""} ${firstScreen?.subtitle || ""} ${firstScreen?.copy || ""}`;
if (!containsCoreTerms(firstScreenText, coreFocus.focusTerms)) {
issues.push("首屏未聚焦核心卖点锚点,主标题与副标题需直接承接核心卖点。");
}
const offTopicCount = screens.filter((screen) => {
const merged = `${screen.objective} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`;
return !containsCoreTerms(merged, coreFocus.focusTerms);
}).length;
const allowOffTopic = Math.max(1, Math.floor(screens.length * 0.15));
if (offTopicCount > allowOffTopic) {
issues.push("多屏文案偏离核心卖点,整套详情需围绕同一核心卖点展开。");
}
const namingWord = String(firstScreen?.title || "").trim();
if (namingWord) {
const repeatCount = screens
.slice(1)
.filter((screen) => String(`${screen.subtitle} ${screen.copy}`).includes(namingWord)).length;
if (repeatCount < Math.min(2, Math.max(1, screens.length - 1))) {
issues.push("首屏命名词未在后续页面重复强化,建议至少2屏复现以沉淀新搜索词。");
}
}
}
if (hasModelImage) { if (hasModelImage) {
const modelScreenCount = screens.filter((screen) => screen.useModel).length; const modelScreenCount = screens.filter((screen) => screen.useModel).length;
if (modelScreenCount === 0) { if (modelScreenCount === 0) {
@ -910,6 +1260,7 @@ async function runDualDirectorPlanPreflight(params: {
model: any; model: any;
language: "zh" | "en"; language: "zh" | "en";
brief: DetailBrief; brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
globalStyle: DetailPlanResponse["globalStyle"]; globalStyle: DetailPlanResponse["globalStyle"];
screens: DetailPlanScreen[]; screens: DetailPlanScreen[];
hasModelImage: boolean; hasModelImage: boolean;
@ -958,6 +1309,7 @@ async function runDualDirectorPlanPreflight(params: {
"Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.", "Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.",
"Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.", "Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.",
"Must keep canvas 790x1300, unified typography hierarchy, and consistent style.", "Must keep canvas 790x1300, unified typography hierarchy, and consistent style.",
`All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`,
params.hasModelImage params.hasModelImage
? "Model image is provided: only use where needed, keep identity consistent." ? "Model image is provided: only use where needed, keep identity consistent."
: "No model image: all frames must avoid human model dependence.", : "No model image: all frames must avoid human model dependence.",
@ -980,6 +1332,7 @@ async function runDualDirectorPlanPreflight(params: {
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。", "禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。", "禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。", "必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
`整套文案必须围绕同一核心卖点锚点:“${params.coreFocus.anchor}”,不得跑题。`,
params.hasModelImage params.hasModelImage
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。" ? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
: "未提供模特图:所有分镜不得依赖模特人物。", : "未提供模特图:所有分镜不得依赖模特人物。",
@ -1095,11 +1448,13 @@ export async function POST(request: Request) {
{ status: 400 }, { status: 400 },
); );
} }
const coreFocus = buildCoreFocusContext(brief, language);
const fallback = buildFallbackPlan({ const fallback = buildFallbackPlan({
prompt, prompt,
count, count,
language, language,
brief, brief,
coreFocus,
referenceImageNotes, referenceImageNotes,
hasModelImage, hasModelImage,
hasBundleImages, hasBundleImages,
@ -1160,6 +1515,10 @@ export async function POST(request: Request) {
`输出语言:${language === "en" ? "English" : "中文"}`, `输出语言:${language === "en" ? "English" : "中文"}`,
"任务类型:电商详情页“AI详情生成”", "任务类型:电商详情页“AI详情生成”",
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。", "必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
"首屏命名规则:用核心卖点给产品命名(如“甩甩杆/安敏裤”),避免通用标题。",
"命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。",
referenceImageNotes.length > 0 referenceImageNotes.length > 0
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}` ? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
: "", : "",
@ -1201,6 +1560,11 @@ export async function POST(request: Request) {
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` +
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`5.1) 首屏必须输出“The One核心卖点标题”(3-10字)+ 单句结果副标题,直接回答“为什么值得买”。\n` +
`5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` +
`5.3) 中文首屏标题优先使用“核心卖点命名词+品类后缀”结构(如:甩甩杆、安敏裤)。\n` +
`5.4) 命名词必须大白话、好记、可口头传播,避免“智能/科技/方案/系统”等抽象词。\n` +
`5.5) 后续至少2屏在副标题或正文复现该命名词,强化用户检索记忆。\n` +
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` + `6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` + `7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
@ -1221,7 +1585,7 @@ export async function POST(request: Request) {
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` ? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + : `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
`输出格式:\n` + `输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"3-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
temperature: 0.25, temperature: 0.25,
maxTokens: 1800, maxTokens: 1800,
} as any); } as any);
@ -1276,22 +1640,29 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const qualityChecksInitial = evaluatePlanQuality({ const coreFocusedScreens = enforceCoreFocusOnScreens({
screens: normalizedScreens, screens: normalizedScreens,
coreFocus,
language,
});
const qualityChecksInitial = evaluatePlanQuality({
screens: coreFocusedScreens,
hasModelImage, hasModelImage,
hasBundleImages, hasBundleImages,
coreFocus,
}); });
const preflight = await runDualDirectorPlanPreflight({ const preflight = await runDualDirectorPlanPreflight({
model, model,
language, language,
brief, brief,
coreFocus,
globalStyle: globalStyleInitial, globalStyle: globalStyleInitial,
screens: normalizedScreens, screens: coreFocusedScreens,
hasModelImage, hasModelImage,
hasBundleImages, hasBundleImages,
}); });
const preflightScreens = preflight.candidateScreens.map((item) => const preflightScreensRaw = preflight.candidateScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
@ -1300,10 +1671,16 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const preflightScreens = enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
});
const qualityChecksAfterPreflight = evaluatePlanQuality({ const qualityChecksAfterPreflight = evaluatePlanQuality({
screens: preflightScreens, screens: preflightScreens,
hasModelImage, hasModelImage,
hasBundleImages, hasBundleImages,
coreFocus,
}); });
const preflightPassed = const preflightPassed =
(preflight.passed || preflight.rewriteApplied) && (preflight.passed || preflight.rewriteApplied) &&

View File

@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye } from "lucide-react"; import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@ -1146,6 +1146,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null); const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null);
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null); const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]); const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState<string | null>(null);
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({ const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
productSpu: "", productSpu: "",
demandType: "", demandType: "",
@ -3365,6 +3366,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)"); toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)");
}, []); }, []);
const deleteDetailArchive = useCallback(
async (archiveId: string) => {
if (!archiveId || deletingDetailArchiveId === archiveId) return;
const targetArchive = detailArchives.find((item) => item.id === archiveId);
const targetTitle = targetArchive?.title || "该存档";
if (!window.confirm(`确认删除“${targetTitle}”吗?删除后不可恢复。`)) {
return;
}
setDeletingDetailArchiveId(archiveId);
try {
const response = await fetch(
`/api/canvas/${canvasId}/detail-archives?archiveId=${encodeURIComponent(archiveId)}`,
{ method: "DELETE" }
);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
throw new Error(String(result?.error || "删除失败"));
}
setDetailArchives((prev) => prev.filter((item) => item.id !== archiveId));
toast.success("已删除历史存档");
} catch (error) {
console.error("[CanvasChat] Failed to delete detail archive:", error);
toast.error(error instanceof Error ? error.message : "删除历史存档失败");
} finally {
setDeletingDetailArchiveId((current) => (current === archiveId ? null : current));
}
},
[canvasId, deletingDetailArchiveId, detailArchives]
);
const handleDetailGeneration = useCallback(async () => { const handleDetailGeneration = useCallback(async () => {
if (isDetailGenerating) return; if (isDetailGenerating) return;
@ -6551,13 +6584,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<span>{archive.imageCount || 0}张输出</span> <span>{archive.imageCount || 0}张输出</span>
</div> </div>
</div> </div>
<button <div className="flex shrink-0 items-center gap-1.5">
type="button" <button
className="shrink-0 rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground" type="button"
onClick={() => loadDetailArchive(archive)} className="rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
> onClick={() => loadDetailArchive(archive)}
加载 >
</button> 加载
</button>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/50 bg-background text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void deleteDetailArchive(archive.id)}
disabled={deletingDetailArchiveId === archive.id}
title="删除存档"
aria-label="删除存档"
>
{deletingDetailArchiveId === archive.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,6 +1,14 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import * as fal from "@fal-ai/serverless-client"; import * as fal from "@fal-ai/serverless-client";
import { import {
Dialog, Dialog,
@ -17,6 +25,9 @@ import {
Eraser, Eraser,
Loader2, Loader2,
Paintbrush, Paintbrush,
Square,
Circle as CircleIcon,
Upload,
RefreshCw, RefreshCw,
Send, Send,
Sparkles, Sparkles,
@ -64,6 +75,7 @@ type RealtimeCanvasDialogProps = {
}; };
const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。"; const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const CANVAS_BACKGROUND = "#ffffff";
const defaultRealtimeConfig: RealtimeConfig = { const defaultRealtimeConfig: RealtimeConfig = {
transport: { transport: {
@ -100,6 +112,10 @@ function buildImageDataUrl(image: any) {
return ""; return "";
} }
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle") {
return mode === "rect" || mode === "circle";
}
async function canvasToJpegDataUri( async function canvasToJpegDataUri(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
maxInputResolution: number, maxInputResolution: number,
@ -120,7 +136,7 @@ async function canvasToJpegDataUri(
throw new Error("无法初始化导出画布"); throw new Error("无法初始化导出画布");
} }
exportCtx.fillStyle = "#d8d8db"; exportCtx.fillStyle = CANVAS_BACKGROUND;
exportCtx.fillRect(0, 0, exportWidth, exportHeight); exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight); exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
@ -150,6 +166,8 @@ export function RealtimeCanvasDialog({
const connectionRef = useRef<any>(null); const connectionRef = useRef<any>(null);
const pointerDownRef = useRef(false); const pointerDownRef = useRef(false);
const lastPointRef = useRef<{ x: number; y: number } | null>(null); const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null);
const shapeSnapshotRef = useRef<ImageData | null>(null);
const pendingFlushTimerRef = useRef<number | null>(null); const pendingFlushTimerRef = useRef<number | null>(null);
const flushInFlightRef = useRef(false); const flushInFlightRef = useRef(false);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
@ -165,6 +183,7 @@ export function RealtimeCanvasDialog({
const historyRef = useRef<ImageData[]>([]); const historyRef = useRef<ImageData[]>([]);
const historyIndexRef = useRef(-1); const historyIndexRef = useRef(-1);
const cursorRef = useRef<HTMLDivElement>(null); const cursorRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [canUndo, setCanUndo] = useState(false); const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false); const [canRedo, setCanRedo] = useState(false);
@ -174,7 +193,7 @@ export function RealtimeCanvasDialog({
const [prompt, setPrompt] = useState(FALLBACK_PROMPT); const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState(""); const [previewUrl, setPreviewUrl] = useState("");
const [isApplying, setIsApplying] = useState(false); const [isApplying, setIsApplying] = useState(false);
const [brushMode, setBrushMode] = useState<"brush" | "eraser">("brush"); const [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle">("brush");
const [brushSize, setBrushSize] = useState(6); const [brushSize, setBrushSize] = useState(6);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>( const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval defaultRealtimeConfig.transport.throttleInterval
@ -293,8 +312,11 @@ export function RealtimeCanvasDialog({
); );
const scheduleFlush = useCallback( const scheduleFlush = useCallback(
(force = false) => { (force = false, markDirty = true) => {
dirtyRef.current = true; if (markDirty) {
dirtyRef.current = true;
}
if (!dirtyRef.current) return;
if (force) { if (force) {
if (pendingFlushTimerRef.current) { if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current); window.clearTimeout(pendingFlushTimerRef.current);
@ -351,7 +373,7 @@ export function RealtimeCanvasDialog({
if (!ctx) return; if (!ctx) return;
ctx.save(); ctx.save();
ctx.globalCompositeOperation = "source-over"; ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#d8d8db"; ctx.fillStyle = CANVAS_BACKGROUND;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore(); ctx.restore();
@ -444,64 +466,138 @@ export function RealtimeCanvasDialog({
[brushMode, brushSize] [brushMode, brushSize]
); );
const drawShape = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const minX = Math.min(from.x, to.x);
const minY = Math.min(from.y, to.y);
const width = Math.abs(to.x - from.x);
const height = Math.abs(to.y - from.y);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#18181b";
ctx.lineWidth = Math.max(1, brushSize * 0.9);
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (brushMode === "rect") {
ctx.strokeRect(minX, minY, width, height);
} else if (brushMode === "circle") {
const centerX = minX + width / 2;
const centerY = minY + height / 2;
const radiusX = width / 2;
const radiusY = height / 2;
ctx.beginPath();
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
},
[brushMode, brushSize]
);
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = getCanvasPoint(event); const point = getCanvasPoint(event);
if (!point) return; if (!point) return;
pointerDownRef.current = true; pointerDownRef.current = true;
lastPointRef.current = point; lastPointRef.current = point;
if (isShapeMode(brushMode)) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d", { willReadFrequently: true });
shapeStartPointRef.current = point;
shapeSnapshotRef.current =
canvas && ctx ? ctx.getImageData(0, 0, canvas.width, canvas.height) : null;
}
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
}, },
[getCanvasPoint] [brushMode, getCanvasPoint]
); );
const handlePointerLeave = useCallback(() => { const handlePointerLeave = useCallback(() => {
const wasDrawing = pointerDownRef.current;
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.style.opacity = '0'; cursorRef.current.style.opacity = '0';
} }
pointerDownRef.current = false; pointerDownRef.current = false;
lastPointRef.current = null; lastPointRef.current = null;
scheduleFlush(true); shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
if (wasDrawing) {
scheduleFlush(true, false);
}
}, [scheduleFlush]); }, [scheduleFlush]);
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.style.opacity = '1'; cursorRef.current.style.opacity = brushMode === "brush" || brushMode === "eraser" ? "1" : "0";
} }
}, []); }, [brushMode]);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
// Update cursor // Update cursor
const point = getCanvasPoint(event); const point = getCanvasPoint(event);
if (point && cursorRef.current) { if (point && cursorRef.current) {
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; const showCursor = brushMode === "brush" || brushMode === "eraser";
cursorRef.current.style.width = `${brushSize}px`; if (showCursor) {
cursorRef.current.style.height = `${brushSize}px`; cursorRef.current.style.opacity = "1";
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`;
cursorRef.current.style.width = `${brushSize}px`;
cursorRef.current.style.height = `${brushSize}px`;
} else {
cursorRef.current.style.opacity = "0";
}
} }
if (!pointerDownRef.current) return; if (!pointerDownRef.current) return;
if (!point || !lastPointRef.current) return; if (!point || !lastPointRef.current) return;
drawStroke(lastPointRef.current, point); if (isShapeMode(brushMode) && shapeStartPointRef.current) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && shapeSnapshotRef.current) {
ctx.putImageData(shapeSnapshotRef.current, 0, 0);
drawShape(shapeStartPointRef.current, point);
}
} else {
drawStroke(lastPointRef.current, point);
}
lastPointRef.current = point; lastPointRef.current = point;
scheduleFlush(); scheduleFlush();
}, },
[drawStroke, getCanvasPoint, scheduleFlush, brushSize] [drawShape, drawStroke, getCanvasPoint, scheduleFlush, brushMode, brushSize]
); );
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
pointerDownRef.current = false; pointerDownRef.current = false;
const endPoint = getCanvasPoint(event);
const shapeStart = shapeStartPointRef.current;
if (isShapeMode(brushMode) && endPoint && shapeStart) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && shapeSnapshotRef.current) {
ctx.putImageData(shapeSnapshotRef.current, 0, 0);
drawShape(shapeStart, endPoint);
}
}
lastPointRef.current = null; lastPointRef.current = null;
shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
try { try {
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId);
} catch { } catch {
// ignore // ignore
} }
saveHistory(); // Save state after stroke saveHistory(); // Save state after stroke
scheduleFlush(true); scheduleFlush(true, false);
}, },
[scheduleFlush, saveHistory] [brushMode, drawShape, getCanvasPoint, saveHistory, scheduleFlush]
); );
// Shortcuts // Shortcuts
@ -570,7 +666,7 @@ export function RealtimeCanvasDialog({
if (!open) return; if (!open) return;
initializeCanvas(); initializeCanvas();
void drawInitialImage().then(() => { void drawInitialImage().then(() => {
scheduleFlush(true); scheduleFlush(true, false);
}); });
const onResize = () => initializeCanvas(); const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
@ -588,10 +684,29 @@ export function RealtimeCanvasDialog({
const start = async () => { const start = async () => {
try { try {
const configResponse = await fetch("/api/realtime/config", { cache: "no-store" }); let configResponse: Response | null = null;
const configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig>; let configJson: Partial<RealtimeConfig> & { message?: string; retryAfter?: number } = {};
if (!configResponse.ok) {
throw new Error((configJson as any)?.message || "无法加载实时配置"); for (let attempt = 0; attempt < 3; attempt += 1) {
configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig> & {
message?: string;
retryAfter?: number;
};
if (configResponse.ok) break;
const retryAfterSeconds = Number(configJson.retryAfter || 0);
const shouldRetry429 = configResponse.status === 429 && attempt < 2;
if (!shouldRetry429) {
throw new Error(configJson.message || "无法加载实时配置");
}
const retryDelay = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 700 * (attempt + 1);
await new Promise((resolve) => window.setTimeout(resolve, retryDelay));
}
if (!configResponse?.ok) {
throw new Error(configJson.message || "无法加载实时配置");
} }
if (cancelled) return; if (cancelled) return;
@ -693,7 +808,7 @@ export function RealtimeCanvasDialog({
connectionRef.current = connection; connectionRef.current = connection;
setStatus("connecting"); setStatus("connecting");
setStatusMessage("连接成功,等待首帧..."); setStatusMessage("连接成功,等待首帧...");
scheduleFlush(true); scheduleFlush(true, false);
} catch (error) { } catch (error) {
console.error("[RealtimeCanvas] init failed:", error); console.error("[RealtimeCanvas] init failed:", error);
setStatus("error"); setStatus("error");
@ -734,12 +849,65 @@ export function RealtimeCanvasDialog({
const clearSketch = useCallback(() => { const clearSketch = useCallback(() => {
resetCanvas(); resetCanvas();
dirtyRef.current = true; dirtyRef.current = true;
scheduleFlush(true); scheduleFlush(true, false);
}, [resetCanvas, scheduleFlush]); }, [resetCanvas, scheduleFlush]);
const handleSketchUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
try {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("图片加载失败"));
image.src = objectUrl;
});
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) {
URL.revokeObjectURL(objectUrl);
throw new Error("画布未初始化");
}
const rect = canvas.getBoundingClientRect();
const canvasWidth = Math.max(1, rect.width);
const canvasHeight = Math.max(1, rect.height);
const scale = Math.min(canvasWidth / image.width, canvasHeight / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const offsetX = (canvasWidth - drawWidth) / 2;
const offsetY = (canvasHeight - drawHeight) / 2;
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
URL.revokeObjectURL(objectUrl);
saveHistory();
scheduleFlush(true);
toast.success("图片已导入绘画区");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导入图片失败");
} finally {
event.currentTarget.value = "";
}
},
[saveHistory, scheduleFlush]
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]"> <DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
<DialogHeader className="sr-only">
<DialogTitle>实时画布</DialogTitle>
<DialogDescription>左侧绘制草图,右侧实时预览,并可将结果发送到无限画布。</DialogDescription>
</DialogHeader>
{/* Top Header - Minimal */} {/* Top Header - Minimal */}
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none"> <div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm"> <div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
@ -773,7 +941,10 @@ export function RealtimeCanvasDialog({
<div className="relative"> <div className="relative">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="touch-none shadow-sm block cursor-none" className={cn(
"touch-none shadow-sm block",
brushMode === "brush" || brushMode === "eraser" ? "cursor-none" : "cursor-crosshair"
)}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@ -866,9 +1037,28 @@ export function RealtimeCanvasDialog({
variant={brushMode === "eraser" ? "secondary" : "ghost"} variant={brushMode === "eraser" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")} className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("eraser")} onClick={() => setBrushMode("eraser")}
title="Eraser"
> >
<Eraser className="h-5 w-5" /> <Eraser className="h-5 w-5" />
</Button> </Button>
<Button
size="icon"
variant={brushMode === "rect" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "rect" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("rect")}
title="Rectangle"
>
<Square className="h-5 w-5" />
</Button>
<Button
size="icon"
variant={brushMode === "circle" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "circle" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("circle")}
title="Circle"
>
<CircleIcon className="h-5 w-5" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" /> <div className="h-[1px] bg-border/50 mx-2 my-1" />
<div className="flex flex-col items-center gap-2 py-2"> <div className="flex flex-col items-center gap-2 py-2">
@ -890,9 +1080,26 @@ export function RealtimeCanvasDialog({
variant="ghost" variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive" className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
onClick={clearSketch} onClick={clearSketch}
title="Clear"
> >
<Trash2 className="h-5 w-5" /> <Trash2 className="h-5 w-5" />
</Button> </Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={() => uploadInputRef.current?.click()}
title="Upload Image"
>
<Upload className="h-5 w-5" />
</Button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleSketchUpload}
/>
</div> </div>
</div> </div>
@ -906,7 +1113,7 @@ export function RealtimeCanvasDialog({
<Input <Input
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
onBlur={() => scheduleFlush(true)} onBlur={() => scheduleFlush(true, false)}
placeholder="Describe what you want to create..." placeholder="Describe what you want to create..."
className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none" className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none"
/> />

View File

@ -1504,7 +1504,11 @@ export function LovartHome({
<Briefcase className="h-[18px] w-[18px]" /> <Briefcase className="h-[18px] w-[18px]" />
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2"> <HoverCardContent
side="right"
align="start"
className="w-[248px] rounded-2xl border-black/10 bg-[#f4f4f4]/90 p-2 backdrop-blur-md shadow-[0_16px_40px_rgba(0,0,0,0.12)]"
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50"> <div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox1Name || "工具箱"} {siteSettingsData?.toolbox1Name || "工具箱"}
</div> </div>
@ -1535,7 +1539,11 @@ export function LovartHome({
<Briefcase className="h-[18px] w-[18px]" /> <Briefcase className="h-[18px] w-[18px]" />
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2"> <HoverCardContent
side="right"
align="start"
className="w-[248px] rounded-2xl border-black/10 bg-[#f4f4f4]/90 p-2 backdrop-blur-md shadow-[0_16px_40px_rgba(0,0,0,0.12)]"
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50"> <div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox2Name || "工具箱2"} {siteSettingsData?.toolbox2Name || "工具箱2"}
</div> </div>

View File

@ -77,6 +77,12 @@ export const rateLimiters = {
// 通用API限制:每分钟100次请求 // 通用API限制:每分钟100次请求
api: new RateLimiter(60000, 100), api: new RateLimiter(60000, 100),
// 画布保存接口:每分钟300次请求(含自动保存)
canvas: new RateLimiter(60000, 300),
// 实时画布相关接口:每分钟600次请求(配置/指标/代理)
realtime: new RateLimiter(60000, 600),
// 聊天接口限制:每分钟60次请求(避免被高频轮询接口挤占额度) // 聊天接口限制:每分钟60次请求(避免被高频轮询接口挤占额度)
chat: new RateLimiter(60000, 60), chat: new RateLimiter(60000, 60),

View File

@ -40,6 +40,33 @@ export async function proxy(request: NextRequest) {
{ status: 429 } { status: 429 }
); );
} }
} else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") {
const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas);
if (!canvasRateLimit.allowed) {
console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, {
pathname,
resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
});
return NextResponse.json(
{ error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
} else if (
pathname.startsWith("/api/realtime/") ||
pathname.startsWith("/api/fal/proxy")
) {
const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime);
if (!realtimeRateLimit.allowed) {
console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, {
pathname,
resetTime: new Date(realtimeRateLimit.resetTime).toISOString(),
});
return NextResponse.json(
{ error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) },
{ status: 429 }
);
}
} else if (pathname.startsWith("/api/")) { } else if (pathname.startsWith("/api/")) {
const rateLimit = checkRateLimit(request, rateLimiters.api); const rateLimit = checkRateLimit(request, rateLimiters.api);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {

View File

@ -91,13 +91,28 @@ export const canvasService = {
// 保存画布状态 // 保存画布状态
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> { async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> {
try { try {
const response = await fetch(`/api/canvas/${id}`, { const requestBody = JSON.stringify({ canvasData });
method: 'PUT', const doSave = async () =>
headers: { 'Content-Type': 'application/json' }, fetch(`/api/canvas/${id}`, {
body: JSON.stringify({ canvasData }), method: 'PUT',
}); headers: { 'Content-Type': 'application/json' },
const data = await response.json(); body: requestBody,
return data; });
let response = await doSave();
if (response.status === 429) {
const retryPayload = await response.json().catch(() => ({}));
const retryAfter = Number(retryPayload?.retryAfter || 0);
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 800;
await new Promise((resolve) => setTimeout(resolve, waitMs));
response = await doSave();
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
return { success: false, error: String(data?.error || data?.message || '保存画布状态失败') };
}
return data as ApiResponse;
} catch (error) { } catch (error) {
return { success: false, error: '保存画布状态失败' }; return { success: false, error: '保存画布状态失败' };
} }