fix: persist detail generation archives during in-progress runs
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-20 12:11:40 +08:00
parent 87f645eb3b
commit ac51a8c6f5
2 changed files with 221 additions and 19 deletions

View File

@ -103,7 +103,7 @@ export async function GET(
eq(canvasDetailArchive.userId, session.user.id)
)
)
.orderBy(desc(canvasDetailArchive.createdAt))
.orderBy(desc(canvasDetailArchive.updatedAt))
.limit(30);
return NextResponse.json({ success: true, data: archives });
@ -161,3 +161,66 @@ export async function POST(
return NextResponse.json({ success: false, error: "保存详情存档失败" }, { status: 500 });
}
}
export async function PATCH(
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 body = await request.json().catch(() => ({}));
const archiveId = String((body as any)?.archiveId || "").trim();
const archiveRaw = ((body as any)?.archive || {}) as Record<string, unknown>;
if (!archiveId) {
return NextResponse.json({ success: false, error: "缺少存档ID" }, { status: 400 });
}
const title = String(archiveRaw.title || "").trim() || "AI详情项目";
const prompt = String(archiveRaw.prompt || "").replace(/\s+/g, " ").trim();
const summary = String(archiveRaw.summary || "").trim();
const imageCount = Number(archiveRaw.imageCount);
const coverUrl = String(archiveRaw.coverUrl || "").trim();
const brief = sanitizeBrief(archiveRaw.brief);
const slots = sanitizeSlots(archiveRaw.slots);
const [updated] = await db
.update(canvasDetailArchive)
.set({
title,
prompt,
brief,
slots,
summary: summary || null,
imageCount: Number.isFinite(imageCount) ? Math.max(0, Math.round(imageCount)) : null,
coverUrl: coverUrl || null,
updatedAt: new Date(),
})
.where(
and(
eq(canvasDetailArchive.id, archiveId),
eq(canvasDetailArchive.projectId, projectId),
eq(canvasDetailArchive.userId, session.user.id)
)
)
.returning();
if (!updated) {
return NextResponse.json({ success: false, error: "存档不存在或无权限" }, { status: 404 });
}
return NextResponse.json({ success: true, data: updated });
} catch (error) {
console.error("[CanvasDetailArchive] PATCH failed:", error);
return NextResponse.json({ success: false, error: "更新详情存档失败" }, { status: 500 });
}
}

View File

@ -313,6 +313,7 @@ type DetailArchive = {
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
createdAt: number;
updatedAt?: number;
summary?: string;
imageCount?: number;
coverUrl?: string;
@ -1789,11 +1790,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const timestamp = Date.parse(String(item.createdAt || ""));
return Number.isFinite(timestamp) ? timestamp : Date.now();
})(),
updatedAt:
typeof (item as any).updatedAt === "number"
? Number((item as any).updatedAt)
: (() => {
const timestamp = Date.parse(String((item as any).updatedAt || ""));
return Number.isFinite(timestamp) ? timestamp : undefined;
})(),
summary: item.summary ? String(item.summary) : undefined,
imageCount: Number(item.imageCount || 0) || undefined,
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
}))
.sort((a, b) => b.createdAt - a.createdAt);
.sort(
(a, b) =>
(b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)
);
setDetailArchives(sanitized.slice(0, 30));
} catch (error) {
@ -2523,8 +2534,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[updateDetailSlotAttachment]
);
const saveDetailArchiveToDatabase = useCallback(
async (archive: DetailArchive) => {
const updateLocalDetailArchive = useCallback(
(archiveId: string, patch: Partial<DetailArchive>) => {
setDetailArchives((prev) =>
prev.map((item) =>
item.id === archiveId
? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() }
: item
)
);
},
[]
);
const createDetailArchiveInDatabase = useCallback(
async (archive: DetailArchive): Promise<DetailArchive | null> => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "POST",
@ -2534,25 +2558,64 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
body: JSON.stringify({ archive }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
if (!response.ok || !result?.success || !result?.data?.id) {
console.error("[CanvasChat] Failed to persist detail archive:", result?.error || response.statusText);
return null;
}
const createdAtValue =
typeof result.data.createdAt === "number"
? result.data.createdAt
: Date.parse(String(result.data.createdAt || ""));
return {
...archive,
id: String(result.data.id),
createdAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
updatedAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
};
} catch (error) {
console.error("[CanvasChat] Failed to persist detail archive:", error);
return null;
}
},
[canvasId]
);
const patchDetailArchiveInDatabase = useCallback(
async (archiveId: string, archive: DetailArchive) => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ archiveId, archive }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
console.error(
"[CanvasChat] Failed to update detail archive:",
result?.error || response.statusText
);
return false;
}
return true;
} catch (error) {
console.error("[CanvasChat] Failed to update detail archive:", error);
return false;
}
},
[canvasId]
);
const saveDetailArchiveSnapshot = useCallback(
(params: {
async (params: {
summary?: string;
imageCount?: number;
coverUrl?: string;
prompt: string;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
}) => {
}): Promise<DetailArchive | null> => {
const archiveId = generateUUID();
const promptText = params.prompt.replace(/\s+/g, " ").trim();
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
@ -2583,15 +2646,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: null;
}),
createdAt,
updatedAt: createdAt,
summary: params.summary,
imageCount: params.imageCount,
coverUrl: params.coverUrl,
};
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
void saveDetailArchiveToDatabase(archive);
const saved = await createDetailArchiveInDatabase(archive);
if (saved?.id) {
setDetailArchives((prev) => {
const next = [saved, ...prev.filter((item) => item.id !== archive.id && item.id !== saved.id)];
return next.slice(0, 30);
});
return saved;
}
return archive;
},
[saveDetailArchiveToDatabase]
[createDetailArchiveInDatabase]
);
const loadDetailArchive = useCallback((archive: DetailArchive) => {
@ -2727,6 +2799,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
.filter(Boolean)
.join("\n");
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh";
let workingArchive: DetailArchive | null = null;
let completedDetailImageCount = 0;
const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = [];
setIsDetailGenerating(true);
setDetailProgress({
@ -2736,6 +2811,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
total: Math.max(1, detailCount + 1 + DETAIL_REVIEW_COUNT),
});
try {
workingArchive = await saveDetailArchiveSnapshot({
summary: `进行中:已完成 0/${detailCount} 张详情图`,
imageCount: 0,
coverUrl: undefined,
prompt: archiveSnapshot.prompt,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
const detailReferenceAttachments = DETAIL_SLOT_CONFIGS.map((config, index) => {
const current = detailSlots[index];
if (!current?.url) return null;
@ -2835,8 +2919,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: siteSettings?.canvasAutoModeDefaultModelId ||
siteSettings?.canvasDefaultModelId ||
undefined;
const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = [];
for (let index = 0; index < prompts.length; index += 1) {
const screen = screenSpecs[index];
const useModelForScene = Boolean(screen?.useModel);
@ -2940,6 +3022,25 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const generated = generationResult.data.images[0]!;
generatedImages.push(generated);
completedDetailImageCount = generatedImages.length;
if (workingArchive?.id) {
const progressSummary = `进行中:已完成 ${completedDetailImageCount}/${prompts.length} 张详情图`;
const progressCoverUrl = generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl;
const nextArchive: DetailArchive = {
...workingArchive,
summary: progressSummary,
imageCount: completedDetailImageCount,
coverUrl: progressCoverUrl,
};
workingArchive = nextArchive;
updateLocalDetailArchive(nextArchive.id, {
summary: progressSummary,
imageCount: completedDetailImageCount,
coverUrl: progressCoverUrl,
});
await patchDetailArchiveInDatabase(nextArchive.id, nextArchive);
}
const dimensions = await getImageDimensions(
generated.originalUrl || generated.thumbnailUrl
@ -3140,14 +3241,35 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
current: totalSteps,
total: totalSteps,
});
saveDetailArchiveSnapshot({
summary: planData.summary,
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
prompt: archiveSnapshot.prompt,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
const finalSummary =
planData.summary ||
(reviewFailures.length > 0
? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条`
: `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`);
if (workingArchive?.id) {
const finalArchive: DetailArchive = {
...workingArchive,
summary: finalSummary,
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
};
workingArchive = finalArchive;
updateLocalDetailArchive(finalArchive.id, {
summary: finalArchive.summary,
imageCount: finalArchive.imageCount,
coverUrl: finalArchive.coverUrl,
});
await patchDetailArchiveInDatabase(finalArchive.id, finalArchive);
} else {
await saveDetailArchiveSnapshot({
summary: finalSummary,
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
prompt: archiveSnapshot.prompt,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
}
if (reviewFailures.length > 0) {
toast.warning(
@ -3160,6 +3282,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
if (workingArchive?.id) {
const failedSummary = `异常中断:已完成 ${completedDetailImageCount} 张详情图,错误:${errMsg}`;
const failedArchive: DetailArchive = {
...workingArchive,
summary: failedSummary,
imageCount: completedDetailImageCount,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
};
updateLocalDetailArchive(failedArchive.id, {
summary: failedSummary,
imageCount: completedDetailImageCount,
coverUrl: failedArchive.coverUrl,
});
void patchDetailArchiveInDatabase(failedArchive.id, failedArchive);
}
setDetailProgress((prev) => ({
status: "error",
stage: errMsg,
@ -3194,11 +3331,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
input,
isDetailGenerating,
normalizedDetailBrief,
patchDetailArchiveInDatabase,
selectedModelId,
saveDetailArchiveSnapshot,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
siteSettings?.canvasDefaultModelId,
updateLocalDetailArchive,
]);
const handleGenerateMoreDetailReviews = useCallback(async () => {