refactor: extract canvas detail archive helpers

This commit is contained in:
Your Name 2026-05-27 10:37:35 +08:00
parent d05eabe258
commit 6a19c43094
3 changed files with 347 additions and 138 deletions

View File

@ -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<DetailArchive>) => {
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<DetailArchive | null> => {
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;

View File

@ -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<DetailBriefForm> | 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<DetailArchive>,
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<PickedObjectAttachment | null>;
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,
};
}

View File

@ -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);
});
});