diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 4e3793585..8fb65313f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -156,6 +156,13 @@ import { buildDetailReviewAppendMarkdown, buildDetailReviewPlans, } from "@/components/canvas/chat-panel/canvas-detail-markdown"; +import { + createDetailArchiveSnapshot, + patchDetailArchiveList, + prependDetailArchive, + replaceSavedDetailArchive, + sanitizeDetailArchiveList, +} from "@/components/canvas/chat-panel/canvas-detail-archive"; import { clampChatRequestText, deriveSessionTitleFromMessages, @@ -3200,96 +3207,7 @@ export function CanvasChatPanel({ return; } - const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : []; - const sanitized = parsed - .filter((item) => item?.id && item?.createdAt) - .map((item) => ({ - id: String(item.id), - title: String(item.title || "未命名详情项目"), - prompt: String(item.prompt || ""), - generationMode: normalizeDetailGenerationMode((item as any).generationMode), - brief: { - productSpu: String(item.brief?.productSpu || ""), - demandType: String(item.brief?.demandType || ""), - productName: String(item.brief?.productName || ""), - targetAudience: String(item.brief?.targetAudience || ""), - mainUser: String(item.brief?.mainUser || ""), - usageScenario: String(item.brief?.usageScenario || ""), - triggerMoment: String(item.brief?.triggerMoment || ""), - coreNeed: String(item.brief?.coreNeed || ""), - riskConcern: String(item.brief?.riskConcern || ""), - expectedResult: String(item.brief?.expectedResult || ""), - coreSellingPoint: String(item.brief?.coreSellingPoint || ""), - positioningStatement: String(item.brief?.positioningStatement || ""), - userVoice: String(item.brief?.userVoice || ""), - competitorReference: String(item.brief?.competitorReference || ""), - priceBand: String(item.brief?.priceBand || ""), - brandTone: String(item.brief?.brandTone || ""), - bannedWords: String(item.brief?.bannedWords || ""), - }, - slots: DETAIL_SLOT_CONFIGS.map((_, index) => { - const slot = item.slots?.[index]; - if (!slot?.url) return null; - return { - url: String(slot.url), - name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`), - contentType: String(slot.contentType || "image/jpeg"), - } as PickedObjectAttachment; - }), - createdAt: - typeof item.createdAt === "number" - ? item.createdAt - : (() => { - 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, - uiSurfaceSnapshot: - item.uiSurfaceSnapshot && - typeof item.uiSurfaceSnapshot === "object" && - !Array.isArray(item.uiSurfaceSnapshot) - ? (item.uiSurfaceSnapshot as UiSurfaceSnapshot) - : null, - uiSurfaceLatestReview: - item.uiSurfaceLatestReview && - typeof item.uiSurfaceLatestReview === "object" && - !Array.isArray(item.uiSurfaceLatestReview) - ? { - screenIndex: Number((item.uiSurfaceLatestReview as any).screenIndex || 0) || undefined, - action: - (item.uiSurfaceLatestReview as any).action === "protocol_patch" || - (item.uiSurfaceLatestReview as any).action === "regenerate_image" || - (item.uiSurfaceLatestReview as any).action === "pass" - ? (item.uiSurfaceLatestReview as any).action - : undefined, - passed: - typeof (item.uiSurfaceLatestReview as any).passed === "boolean" - ? (item.uiSurfaceLatestReview as any).passed - : undefined, - issues: Array.isArray((item.uiSurfaceLatestReview as any).issues) - ? (item.uiSurfaceLatestReview as any).issues - .map((issue: unknown) => String(issue || "").trim()) - .filter(Boolean) - .slice(0, 10) - : [], - } - : null, - })) - .sort( - (a, b) => - (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt) - ); - - setDetailArchives(sanitized.slice(0, 30)); + setDetailArchives(sanitizeDetailArchiveList(result.data)); } catch (error) { if (isCancelled) return; console.error("[CanvasChat] Failed to load detail archives:", error); @@ -5187,13 +5105,7 @@ export function CanvasChatPanel({ const updateLocalDetailArchive = useCallback( (archiveId: string, patch: Partial) => { - setDetailArchives((prev) => - prev.map((item) => - item.id === archiveId - ? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() } - : item - ) - ); + setDetailArchives((prev) => patchDetailArchiveList(prev, archiveId, patch)); }, [] ); @@ -5270,52 +5182,17 @@ export function CanvasChatPanel({ uiSurfaceSnapshot?: UiSurfaceSnapshot | null; uiSurfaceLatestReview?: DetailArchive["uiSurfaceLatestReview"]; }): Promise => { - const archiveId = generateUUID(); - const promptText = params.prompt.replace(/\s+/g, " ").trim(); - const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim(); - const demandSeed = String( - params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || "" - ) - .replace(/\s+/g, " ") - .trim(); const createdAt = Date.now(); - const archiveTitle = - spuSeed && demandSeed - ? `${spuSeed}+${demandSeed}` - : spuSeed || demandSeed || params.brief.productName || "AI详情项目"; - - const archive: DetailArchive = { - id: archiveId, - title: archiveTitle, - prompt: promptText, - generationMode: params.generationMode, - brief: { ...params.brief }, - slots: DETAIL_SLOT_CONFIGS.map((_, index) => { - const slot = params.slots[index]; - return slot - ? { - url: slot.url, - name: slot.name, - contentType: slot.contentType, - } - : null; - }), + const archive = createDetailArchiveSnapshot({ + ...params, + archiveId: generateUUID(), createdAt, - updatedAt: createdAt, - summary: params.summary, - imageCount: params.imageCount, - coverUrl: params.coverUrl, - uiSurfaceSnapshot: params.uiSurfaceSnapshot || null, - uiSurfaceLatestReview: params.uiSurfaceLatestReview || null, - }; + }); - setDetailArchives((prev) => [archive, ...prev].slice(0, 30)); + setDetailArchives((prev) => prependDetailArchive(prev, 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); - }); + setDetailArchives((prev) => replaceSavedDetailArchive(prev, archive.id, saved)); return saved; } return archive; diff --git a/components/canvas/chat-panel/canvas-detail-archive.ts b/components/canvas/chat-panel/canvas-detail-archive.ts new file mode 100644 index 000000000..3a512541a --- /dev/null +++ b/components/canvas/chat-panel/canvas-detail-archive.ts @@ -0,0 +1,187 @@ +import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol"; +import { + type DetailArchive, + type DetailBriefForm, + type DetailGenerationMode, + DETAIL_SLOT_CONFIGS, + normalizeDetailGenerationMode, +} from "@/components/canvas/chat-panel/canvas-detail-config"; +import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; + +const DETAIL_ARCHIVE_LIST_LIMIT = 30; + +function toTimestamp(value: unknown, fallback?: number): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + const parsed = Date.parse(String(value || "")); + if (Number.isFinite(parsed)) { + return parsed; + } + return fallback; +} + +function toDetailBriefForm(value: Partial | null | undefined): DetailBriefForm { + return { + productSpu: String(value?.productSpu || ""), + demandType: String(value?.demandType || ""), + productName: String(value?.productName || ""), + targetAudience: String(value?.targetAudience || ""), + mainUser: String(value?.mainUser || ""), + usageScenario: String(value?.usageScenario || ""), + triggerMoment: String(value?.triggerMoment || ""), + coreNeed: String(value?.coreNeed || ""), + riskConcern: String(value?.riskConcern || ""), + expectedResult: String(value?.expectedResult || ""), + coreSellingPoint: String(value?.coreSellingPoint || ""), + positioningStatement: String(value?.positioningStatement || ""), + userVoice: String(value?.userVoice || ""), + competitorReference: String(value?.competitorReference || ""), + priceBand: String(value?.priceBand || ""), + brandTone: String(value?.brandTone || ""), + bannedWords: String(value?.bannedWords || ""), + }; +} + +export function sanitizeDetailArchiveList(value: unknown): DetailArchive[] { + const parsed = Array.isArray(value) ? (value as DetailArchive[]) : []; + return parsed + .filter((item) => item?.id && item?.createdAt) + .map((item) => { + const createdAt = toTimestamp(item.createdAt, Date.now()) || Date.now(); + return { + id: String(item.id), + title: String(item.title || "未命名详情项目"), + prompt: String(item.prompt || ""), + generationMode: normalizeDetailGenerationMode((item as any).generationMode), + brief: toDetailBriefForm(item.brief), + slots: DETAIL_SLOT_CONFIGS.map((_, index) => { + const slot = item.slots?.[index]; + if (!slot?.url) return null; + return { + url: String(slot.url), + name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`), + contentType: String(slot.contentType || "image/jpeg"), + } as PickedObjectAttachment; + }), + createdAt, + updatedAt: toTimestamp((item as any).updatedAt), + summary: item.summary ? String(item.summary) : undefined, + imageCount: Number(item.imageCount || 0) || undefined, + coverUrl: item.coverUrl ? String(item.coverUrl) : undefined, + uiSurfaceSnapshot: + item.uiSurfaceSnapshot && + typeof item.uiSurfaceSnapshot === "object" && + !Array.isArray(item.uiSurfaceSnapshot) + ? (item.uiSurfaceSnapshot as UiSurfaceSnapshot) + : null, + uiSurfaceLatestReview: + item.uiSurfaceLatestReview && + typeof item.uiSurfaceLatestReview === "object" && + !Array.isArray(item.uiSurfaceLatestReview) + ? { + screenIndex: + Number((item.uiSurfaceLatestReview as any).screenIndex || 0) || undefined, + action: + (item.uiSurfaceLatestReview as any).action === "protocol_patch" || + (item.uiSurfaceLatestReview as any).action === "regenerate_image" || + (item.uiSurfaceLatestReview as any).action === "pass" + ? (item.uiSurfaceLatestReview as any).action + : undefined, + passed: + typeof (item.uiSurfaceLatestReview as any).passed === "boolean" + ? (item.uiSurfaceLatestReview as any).passed + : undefined, + issues: Array.isArray((item.uiSurfaceLatestReview as any).issues) + ? (item.uiSurfaceLatestReview as any).issues + .map((issue: unknown) => String(issue || "").trim()) + .filter(Boolean) + .slice(0, 10) + : [], + } + : null, + }; + }) + .sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)) + .slice(0, DETAIL_ARCHIVE_LIST_LIMIT); +} + +export function patchDetailArchiveList( + archives: DetailArchive[], + archiveId: string, + patch: Partial, + now = Date.now() +): DetailArchive[] { + return archives.map((item) => + item.id === archiveId ? { ...item, ...patch, updatedAt: patch.updatedAt || now } : item + ); +} + +export function replaceSavedDetailArchive( + archives: DetailArchive[], + draftArchiveId: string, + savedArchive: DetailArchive +): DetailArchive[] { + return [ + savedArchive, + ...archives.filter((item) => item.id !== draftArchiveId && item.id !== savedArchive.id), + ].slice(0, DETAIL_ARCHIVE_LIST_LIMIT); +} + +export function prependDetailArchive( + archives: DetailArchive[], + archive: DetailArchive +): DetailArchive[] { + return [archive, ...archives].slice(0, DETAIL_ARCHIVE_LIST_LIMIT); +} + +export function createDetailArchiveSnapshot(params: { + archiveId: string; + createdAt: number; + summary?: string; + imageCount?: number; + coverUrl?: string; + prompt: string; + generationMode: DetailGenerationMode; + brief: DetailBriefForm; + slots: Array; + uiSurfaceSnapshot?: UiSurfaceSnapshot | null; + uiSurfaceLatestReview?: DetailArchive["uiSurfaceLatestReview"]; +}): DetailArchive { + const promptText = params.prompt.replace(/\s+/g, " ").trim(); + const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim(); + const demandSeed = String( + params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || "" + ) + .replace(/\s+/g, " ") + .trim(); + const archiveTitle = + spuSeed && demandSeed + ? `${spuSeed}+${demandSeed}` + : spuSeed || demandSeed || params.brief.productName || "AI详情项目"; + + return { + id: params.archiveId, + title: archiveTitle, + prompt: promptText, + generationMode: params.generationMode, + brief: { ...params.brief }, + slots: DETAIL_SLOT_CONFIGS.map((_, index) => { + const slot = params.slots[index]; + return slot + ? { + url: slot.url, + name: slot.name, + contentType: slot.contentType, + } + : null; + }), + createdAt: params.createdAt, + updatedAt: params.createdAt, + summary: params.summary, + imageCount: params.imageCount, + coverUrl: params.coverUrl, + uiSurfaceSnapshot: params.uiSurfaceSnapshot || null, + uiSurfaceLatestReview: params.uiSurfaceLatestReview || null, + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index b58476089..95cfc5853 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -26,6 +26,13 @@ import { sanitizeDetailImageModelPrompt, splitDetailKeywords, } from "@/components/canvas/chat-panel/canvas-detail-config"; +import { + createDetailArchiveSnapshot, + patchDetailArchiveList, + prependDetailArchive, + replaceSavedDetailArchive, + sanitizeDetailArchiveList, +} from "@/components/canvas/chat-panel/canvas-detail-archive"; import type { ChatMessage } from "@/lib/types"; test.describe("canvas chat pure helpers", () => { @@ -259,4 +266,142 @@ test.describe("canvas chat pure helpers", () => { }) ).toContain("失败 1 条,已跳过。"); }); + + test("sanitizes and sorts detail archives from persisted payloads", () => { + const archives = sanitizeDetailArchiveList([ + { + id: "older", + title: "", + prompt: " old prompt ", + generationMode: "invalid", + brief: { + productName: "旧产品", + targetAudience: "人群", + }, + slots: [ + { + url: "https://cdn.example.com/product.png", + name: "", + contentType: "", + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + uiSurfaceLatestReview: { + screenIndex: 2, + action: "unsafe", + passed: true, + issues: [" issue-a ", "", "issue-b"], + }, + }, + { + id: "newer", + title: "新项目", + prompt: "new prompt", + generationMode: "agent_layout", + brief: { + coreNeed: "核心需求", + }, + slots: [], + createdAt: "2026-01-03T00:00:00.000Z", + }, + { + id: "", + createdAt: "2026-01-04T00:00:00.000Z", + }, + ]); + + expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]); + expect(archives[0]?.generationMode).toBe("agent_layout"); + expect(archives[1]).toMatchObject({ + title: "未命名详情项目", + generationMode: "classic", + brief: { + productName: "旧产品", + coreNeed: "", + }, + }); + expect(archives[1]?.slots[0]).toMatchObject({ + url: "https://cdn.example.com/product.png", + name: "产品图1", + contentType: "image/jpeg", + }); + expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({ + screenIndex: 2, + action: undefined, + passed: true, + issues: ["issue-a", "issue-b"], + }); + }); + + test("creates and updates detail archive lists with stable limits", () => { + const archive = createDetailArchiveSnapshot({ + archiveId: "draft", + createdAt: 1000, + prompt: " 第一行\n第二行 ", + generationMode: "classic", + summary: "摘要", + imageCount: 2, + coverUrl: "https://cdn.example.com/cover.png", + brief: { + productSpu: " SPU-001 ", + demandType: "", + productName: "产品名", + targetAudience: "", + mainUser: "", + usageScenario: " 客厅清洁 ", + triggerMoment: "", + coreNeed: "", + riskConcern: "", + expectedResult: "", + coreSellingPoint: "", + positioningStatement: "", + userVoice: "", + competitorReference: "", + priceBand: "", + brandTone: "", + bannedWords: "", + }, + slots: [ + { + name: "产品", + url: "https://cdn.example.com/product.png", + contentType: "image/png", + }, + null, + ], + }); + + expect(archive).toMatchObject({ + id: "draft", + title: "SPU-001+客厅清洁", + prompt: "第一行 第二行", + createdAt: 1000, + updatedAt: 1000, + summary: "摘要", + imageCount: 2, + coverUrl: "https://cdn.example.com/cover.png", + }); + expect(archive.slots[0]).toMatchObject({ + name: "产品", + url: "https://cdn.example.com/product.png", + contentType: "image/png", + }); + + const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000); + expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 }); + + const saved = { ...archive, id: "saved", updatedAt: 3000 }; + expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([ + "saved", + ]); + + const manyArchives = Array.from({ length: 35 }, (_, index) => ({ + ...archive, + id: `archive-${index}`, + createdAt: index, + updatedAt: index, + })); + expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30); + }); });