From ac51a8c6f521bf2bd44dcc6737e1bfaee9d01c20 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 12:11:40 +0800 Subject: [PATCH] fix: persist detail generation archives during in-progress runs --- app/api/canvas/[id]/detail-archives/route.ts | 65 ++++++- components/canvas/CanvasChatPanel.tsx | 175 +++++++++++++++++-- 2 files changed, 221 insertions(+), 19 deletions(-) diff --git a/app/api/canvas/[id]/detail-archives/route.ts b/app/api/canvas/[id]/detail-archives/route.ts index c72363082..8f56d78d6 100644 --- a/app/api/canvas/[id]/detail-archives/route.ts +++ b/app/api/canvas/[id]/detail-archives/route.ts @@ -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; + 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 }); + } +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 4c285c012..8e2747266 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -313,6 +313,7 @@ type DetailArchive = { brief: DetailBriefForm; slots: Array; 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) => { + setDetailArchives((prev) => + prev.map((item) => + item.id === archiveId + ? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() } + : item + ) + ); + }, + [] + ); + + const createDetailArchiveInDatabase = useCallback( + async (archive: DetailArchive): Promise => { 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; - }) => { + }): Promise => { 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 () => {