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 });
}
}
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;
};
type DetailCoreFocusContext = {
anchor: string;
primaryNeed: string;
firstScreenTitle: string;
firstScreenSubtitle: string;
focusTerms: string[];
};
const AGENT_PLANNING_PHRASE_PATTERNS = [
/第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
/本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi,
@ -249,6 +257,294 @@ function visibleTitleLength(value: string): number {
.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") {
if (language === "en") {
return {
@ -467,6 +763,7 @@ function buildFallbackScreens(params: {
count: number;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
globalStyle: DetailPlanResponse["globalStyle"];
referenceImageNotes: string[];
hasModelImage: boolean;
@ -478,6 +775,7 @@ function buildFallbackScreens(params: {
count,
language,
brief,
coreFocus,
globalStyle,
referenceImageNotes,
hasModelImage,
@ -510,8 +808,20 @@ function buildFallbackScreens(params: {
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
const defaultTitles =
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) => {
const screenType =
@ -535,17 +845,19 @@ function buildFallbackScreens(params: {
? language === "en"
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
: index === 0
? coreFocus.firstScreenSubtitle
: language === "en"
? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
: `在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
: `围绕“${coreFocus.anchor}”,在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
const copy =
isBundleScreen
? language === "en"
? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.`
: `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。`
: language === "en"
? `${productLabel} turns key pain points into visible outcomes with a clear decision path.`
: `${productLabel}将关键痛点转为可感知结果,形成清晰购买决策路径。`;
? `${productLabel} turns key pain points into visible outcomes under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.`
: `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`;
const layoutNote =
isBundleScreen
? language === "en"
@ -593,6 +905,7 @@ function buildFallbackScreens(params: {
`Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
"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}.`,
`Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
@ -616,6 +929,7 @@ function buildFallbackScreens(params: {
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};核心需求:${coreNeed}。`,
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
`核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`,
`一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
@ -655,6 +969,7 @@ function buildFallbackPlan(params: {
count: number;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
referenceImageNotes: string[];
hasModelImage: boolean;
hasBundleImages: boolean;
@ -663,14 +978,20 @@ function buildFallbackPlan(params: {
const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = buildDetailSkillFlow(language);
const screens = buildFallbackScreens({
const fallbackScreens = buildFallbackScreens({
...params,
globalStyle,
});
const screens = enforceCoreFocusOnScreens({
screens: fallbackScreens,
coreFocus: params.coreFocus,
language,
});
const qualityChecks = evaluatePlanQuality({
screens,
hasModelImage: params.hasModelImage,
hasBundleImages: params.hasBundleImages,
coreFocus: params.coreFocus,
});
return {
summary:
@ -810,8 +1131,9 @@ function evaluatePlanQuality(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
hasBundleImages: boolean;
coreFocus?: DetailCoreFocusContext;
}): { passed: boolean; issues: string[] } {
const { screens, hasModelImage, hasBundleImages } = params;
const { screens, hasModelImage, hasBundleImages, coreFocus } = params;
const issues: string[] = [];
if (screens.length === 0) {
issues.push("未生成任何分镜。");
@ -862,12 +1184,13 @@ function evaluatePlanQuality(params: {
issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。");
}
const titleLengthOutOfRange = screens.some((screen) => {
const titleLengthOutOfRange = screens.some((screen, index) => {
const len = visibleTitleLength(screen.title);
return len < 4 || len > 10;
const minLen = index === 0 ? 3 : 4;
return len < minLen || len > 10;
});
if (titleLengthOutOfRange) {
issues.push("存在分镜主标题不在4-10字区间。");
issues.push("存在分镜主标题长度不符合规范(首屏3-10字,其余4-10字)。");
}
const subtitleTooLong = screens.some((screen) => {
@ -886,6 +1209,33 @@ function evaluatePlanQuality(params: {
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) {
const modelScreenCount = screens.filter((screen) => screen.useModel).length;
if (modelScreenCount === 0) {
@ -910,6 +1260,7 @@ async function runDualDirectorPlanPreflight(params: {
model: any;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
globalStyle: DetailPlanResponse["globalStyle"];
screens: DetailPlanScreen[];
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.",
"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.",
`All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`,
params.hasModelImage
? "Model image is provided: only use where needed, keep identity consistent."
: "No model image: all frames must avoid human model dependence.",
@ -980,6 +1332,7 @@ async function runDualDirectorPlanPreflight(params: {
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
`整套文案必须围绕同一核心卖点锚点:“${params.coreFocus.anchor}”,不得跑题。`,
params.hasModelImage
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
: "未提供模特图:所有分镜不得依赖模特人物。",
@ -1095,11 +1448,13 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
const coreFocus = buildCoreFocusContext(brief, language);
const fallback = buildFallbackPlan({
prompt,
count,
language,
brief,
coreFocus,
referenceImageNotes,
hasModelImage,
hasBundleImages,
@ -1160,6 +1515,10 @@ export async function POST(request: Request) {
`输出语言:${language === "en" ? "English" : "中文"}`,
"任务类型:电商详情页“AI详情生成”",
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
"首屏命名规则:用核心卖点给产品命名(如“甩甩杆/安敏裤”),避免通用标题。",
"命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。",
referenceImageNotes.length > 0
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
: "",
@ -1201,6 +1560,11 @@ export async function POST(request: Request) {
`3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` +
`4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\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` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
@ -1221,7 +1585,7 @@ export async function POST(request: Request) {
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
`输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","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,
maxTokens: 1800,
} as any);
@ -1276,22 +1640,29 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const qualityChecksInitial = evaluatePlanQuality({
const coreFocusedScreens = enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
});
const qualityChecksInitial = evaluatePlanQuality({
screens: coreFocusedScreens,
hasModelImage,
hasBundleImages,
coreFocus,
});
const preflight = await runDualDirectorPlanPreflight({
model,
language,
brief,
coreFocus,
globalStyle: globalStyleInitial,
screens: normalizedScreens,
screens: coreFocusedScreens,
hasModelImage,
hasBundleImages,
});
const preflightScreens = preflight.candidateScreens.map((item) =>
const preflightScreensRaw = preflight.candidateScreens.map((item) =>
hasBundleImages
? item
: {
@ -1300,10 +1671,16 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
}
);
const preflightScreens = enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
});
const qualityChecksAfterPreflight = evaluatePlanQuality({
screens: preflightScreens,
hasModelImage,
hasBundleImages,
coreFocus,
});
const preflightPassed =
(preflight.passed || preflight.rewriteApplied) &&

View File

@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
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 { Textarea } from "@/components/ui/textarea";
import {
@ -1146,6 +1146,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null);
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState<string | null>(null);
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
productSpu: "",
demandType: "",
@ -3365,6 +3366,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
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 () => {
if (isDetailGenerating) return;
@ -6551,13 +6584,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<span>{archive.imageCount || 0}张输出</span>
</div>
</div>
<button
type="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"
onClick={() => loadDetailArchive(archive)}
>
加载
</button>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
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
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>

View File

@ -1,6 +1,14 @@
"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 {
Dialog,
@ -17,6 +25,9 @@ import {
Eraser,
Loader2,
Paintbrush,
Square,
Circle as CircleIcon,
Upload,
RefreshCw,
Send,
Sparkles,
@ -64,6 +75,7 @@ type RealtimeCanvasDialogProps = {
};
const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const CANVAS_BACKGROUND = "#ffffff";
const defaultRealtimeConfig: RealtimeConfig = {
transport: {
@ -100,6 +112,10 @@ function buildImageDataUrl(image: any) {
return "";
}
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle") {
return mode === "rect" || mode === "circle";
}
async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
@ -120,7 +136,7 @@ async function canvasToJpegDataUri(
throw new Error("无法初始化导出画布");
}
exportCtx.fillStyle = "#d8d8db";
exportCtx.fillStyle = CANVAS_BACKGROUND;
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
@ -150,6 +166,8 @@ export function RealtimeCanvasDialog({
const connectionRef = useRef<any>(null);
const pointerDownRef = useRef(false);
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 flushInFlightRef = useRef(false);
const dirtyRef = useRef(false);
@ -165,6 +183,7 @@ export function RealtimeCanvasDialog({
const historyRef = useRef<ImageData[]>([]);
const historyIndexRef = useRef(-1);
const cursorRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
@ -174,7 +193,7 @@ export function RealtimeCanvasDialog({
const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState("");
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 [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
@ -293,8 +312,11 @@ export function RealtimeCanvasDialog({
);
const scheduleFlush = useCallback(
(force = false) => {
dirtyRef.current = true;
(force = false, markDirty = true) => {
if (markDirty) {
dirtyRef.current = true;
}
if (!dirtyRef.current) return;
if (force) {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
@ -351,7 +373,7 @@ export function RealtimeCanvasDialog({
if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#d8d8db";
ctx.fillStyle = CANVAS_BACKGROUND;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
@ -444,64 +466,138 @@ export function RealtimeCanvasDialog({
[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(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = getCanvasPoint(event);
if (!point) return;
pointerDownRef.current = true;
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);
},
[getCanvasPoint]
[brushMode, getCanvasPoint]
);
const handlePointerLeave = useCallback(() => {
const wasDrawing = pointerDownRef.current;
if (cursorRef.current) {
cursorRef.current.style.opacity = '0';
}
pointerDownRef.current = false;
lastPointRef.current = null;
scheduleFlush(true);
shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
if (wasDrawing) {
scheduleFlush(true, false);
}
}, [scheduleFlush]);
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity = '1';
cursorRef.current.style.opacity = brushMode === "brush" || brushMode === "eraser" ? "1" : "0";
}
}, []);
}, [brushMode]);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
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`;
const showCursor = brushMode === "brush" || brushMode === "eraser";
if (showCursor) {
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 (!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;
scheduleFlush();
},
[drawStroke, getCanvasPoint, scheduleFlush, brushSize]
[drawShape, drawStroke, getCanvasPoint, scheduleFlush, brushMode, brushSize]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
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;
shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
saveHistory(); // Save state after stroke
scheduleFlush(true);
scheduleFlush(true, false);
},
[scheduleFlush, saveHistory]
[brushMode, drawShape, getCanvasPoint, saveHistory, scheduleFlush]
);
// Shortcuts
@ -570,7 +666,7 @@ export function RealtimeCanvasDialog({
if (!open) return;
initializeCanvas();
void drawInitialImage().then(() => {
scheduleFlush(true);
scheduleFlush(true, false);
});
const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize);
@ -588,10 +684,29 @@ export function RealtimeCanvasDialog({
const start = async () => {
try {
const configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
const configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig>;
if (!configResponse.ok) {
throw new Error((configJson as any)?.message || "无法加载实时配置");
let configResponse: Response | null = null;
let configJson: Partial<RealtimeConfig> & { message?: string; retryAfter?: number } = {};
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;
@ -693,7 +808,7 @@ export function RealtimeCanvasDialog({
connectionRef.current = connection;
setStatus("connecting");
setStatusMessage("连接成功,等待首帧...");
scheduleFlush(true);
scheduleFlush(true, false);
} catch (error) {
console.error("[RealtimeCanvas] init failed:", error);
setStatus("error");
@ -734,12 +849,65 @@ export function RealtimeCanvasDialog({
const clearSketch = useCallback(() => {
resetCanvas();
dirtyRef.current = true;
scheduleFlush(true);
scheduleFlush(true, false);
}, [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 (
<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]">
<DialogHeader className="sr-only">
<DialogTitle>实时画布</DialogTitle>
<DialogDescription>左侧绘制草图,右侧实时预览,并可将结果发送到无限画布。</DialogDescription>
</DialogHeader>
{/* 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="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">
<canvas
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}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@ -866,9 +1037,28 @@ export function RealtimeCanvasDialog({
variant={brushMode === "eraser" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("eraser")}
title="Eraser"
>
<Eraser className="h-5 w-5" />
</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="flex flex-col items-center gap-2 py-2">
@ -890,9 +1080,26 @@ export function RealtimeCanvasDialog({
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
onClick={clearSketch}
title="Clear"
>
<Trash2 className="h-5 w-5" />
</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>
@ -906,7 +1113,7 @@ export function RealtimeCanvasDialog({
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={() => scheduleFlush(true)}
onBlur={() => scheduleFlush(true, false)}
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"
/>

View File

@ -1504,7 +1504,11 @@ export function LovartHome({
<Briefcase className="h-[18px] w-[18px]" />
</button>
</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">
{siteSettingsData?.toolbox1Name || "工具箱"}
</div>
@ -1535,7 +1539,11 @@ export function LovartHome({
<Briefcase className="h-[18px] w-[18px]" />
</button>
</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">
{siteSettingsData?.toolbox2Name || "工具箱2"}
</div>

View File

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

View File

@ -40,6 +40,33 @@ export async function proxy(request: NextRequest) {
{ 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/")) {
const rateLimit = checkRateLimit(request, rateLimiters.api);
if (!rateLimit.allowed) {

View File

@ -91,13 +91,28 @@ export const canvasService = {
// 保存画布状态
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> {
try {
const response = await fetch(`/api/canvas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canvasData }),
});
const data = await response.json();
return data;
const requestBody = JSON.stringify({ canvasData });
const doSave = async () =>
fetch(`/api/canvas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: requestBody,
});
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) {
return { success: false, error: '保存画布状态失败' };
}