feat detail mode: canvas slot picking and local archive load/regenerate flow
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-19 12:34:20 +08:00
parent 9af7619b7f
commit 2332a5d6c8

View File

@ -308,7 +308,19 @@ type DetailSlotConfig = {
role: "product" | "model" | "bundle";
};
type DetailSectionKey = "required" | "strategy" | "assets";
type DetailSectionKey = "required" | "strategy" | "assets" | "archives";
type DetailArchive = {
id: string;
title: string;
prompt: string;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
createdAt: number;
summary?: string;
imageCount?: number;
coverUrl?: string;
};
type DetailPlanResponse = {
summary?: string;
@ -416,6 +428,10 @@ function getSessionStorageKey(canvasId: string): string {
return `canvas-chat-sessions:${canvasId}`;
}
function getDetailArchiveStorageKey(canvasId: string): string {
return `canvas-detail-archives:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
@ -578,6 +594,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
required: true,
strategy: false,
assets: true,
archives: true,
});
const [detailProgress, setDetailProgress] = useState<{
status: "idle" | "running" | "done" | "error";
@ -594,6 +611,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
() => DETAIL_SLOT_CONFIGS.map(() => null)
);
const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null);
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
productName: "",
targetAudience: "",
@ -1334,7 +1353,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
useEffect(() => {
if (isDetailGenerationMode) {
setAttachments([]);
setDetailSections({ required: true, strategy: false, assets: true });
setDetailSections({ required: true, strategy: false, assets: true, archives: true });
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
@ -1499,6 +1518,74 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setDetailSections((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem(getDetailArchiveStorageKey(canvasId));
if (!raw) {
setDetailArchives([]);
return;
}
const parsed = JSON.parse(raw) as DetailArchive[];
const sanitized = Array.isArray(parsed)
? parsed
.filter((item) => item?.id && item?.createdAt)
.map((item) => ({
id: String(item.id),
title: String(item.title || "未命名详情项目"),
prompt: String(item.prompt || ""),
brief: {
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: Number(item.createdAt) || Date.now(),
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)
: [];
setDetailArchives(sanitized.slice(0, 30));
} catch (error) {
console.error("[CanvasChat] Failed to load detail archives:", error);
setDetailArchives([]);
}
}, [canvasId]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(
getDetailArchiveStorageKey(canvasId),
JSON.stringify(detailArchives.slice(0, 30))
);
} catch (error) {
console.error("[CanvasChat] Failed to save detail archives:", error);
}
}, [canvasId, detailArchives]);
useEffect(() => {
if (videoFrameSlots.length === 0) return;
const editableDiv = textareaRef.current as HTMLDivElement | null;
@ -1706,6 +1793,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setInput("");
setAttachments([]);
setIsSessionMenuOpen(false);
setDetailSlotPickerIndex(null);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
@ -2156,6 +2244,87 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[]
);
const fillDetailSlotsFromCanvasSelection = useCallback(
(slotIndex: number) => {
if (slotIndex < 0 || slotIndex >= DETAIL_SLOT_CONFIGS.length) return 0;
if (selectedImageNodes.length === 0) return 0;
const slotRole = DETAIL_SLOT_CONFIGS[slotIndex]?.role;
const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === slotRole ? index : -1
).filter((index) => index >= 0);
const startOffset = sameRoleIndexes.indexOf(slotIndex);
const targetIndexes =
startOffset >= 0 ? sameRoleIndexes.slice(startOffset) : sameRoleIndexes;
if (targetIndexes.length === 0) return 0;
const candidateAttachments = selectedImageNodes
.map((node, index) =>
getCanvasNodeAttachment(
node,
`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}-${index + 1}`
)
)
.filter((item): item is PickedObjectAttachment => Boolean(item));
if (candidateAttachments.length === 0) return 0;
let filledCount = 0;
setDetailSlots((current) => {
const next = [...current];
for (let i = 0; i < targetIndexes.length && i < candidateAttachments.length; i += 1) {
const index = targetIndexes[i]!;
next[index] = candidateAttachments[i]!;
filledCount += 1;
}
return next;
});
return filledCount;
},
[getCanvasNodeAttachment, selectedImageNodes]
);
const activateDetailSlotCanvasPicker = useCallback(
(slotIndex: number) => {
const filled = fillDetailSlotsFromCanvasSelection(slotIndex);
if (filled > 0) {
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
toast.success(
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
);
setDetailSlotPickerIndex(null);
return;
}
setDetailSlotPickerIndex(slotIndex);
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
toast.info(`已选中${label},请在画布中点击图片即可自动填入`);
},
[fillDetailSlotsFromCanvasSelection]
);
useEffect(() => {
if (!isDetailGenerationMode) {
setDetailSlotPickerIndex(null);
return;
}
if (detailSlotPickerIndex === null) return;
if (selectedImageNodes.length === 0) return;
const filled = fillDetailSlotsFromCanvasSelection(detailSlotPickerIndex);
if (filled > 0) {
const label = DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材";
toast.success(
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
);
setDetailSlotPickerIndex(null);
}
}, [
detailSlotPickerIndex,
fillDetailSlotsFromCanvasSelection,
isDetailGenerationMode,
selectedImageNodes.length,
]);
const handleDetailSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@ -2209,6 +2378,83 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[updateDetailSlotAttachment]
);
const saveDetailArchiveSnapshot = useCallback(
(params: {
summary?: string;
imageCount?: number;
coverUrl?: string;
prompt: string;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
}) => {
const archiveId = generateUUID();
const promptText = params.prompt.replace(/\s+/g, " ").trim();
const titleSeed =
params.brief.productName ||
params.brief.coreNeed ||
params.brief.usageScenario ||
"AI详情项目";
const createdAt = Date.now();
const archive: DetailArchive = {
id: archiveId,
title: `${titleSeed}${new Date(createdAt).toLocaleString()}`,
prompt: promptText,
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,
summary: params.summary,
imageCount: params.imageCount,
coverUrl: params.coverUrl,
};
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
},
[]
);
const loadDetailArchive = useCallback((archive: DetailArchive) => {
setIsDetailGenerationMode(true);
setDetailBrief({ ...archive.brief });
setDetailSlots(
DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = archive.slots[index];
return slot
? {
url: slot.url,
name: slot.name,
contentType: slot.contentType || "image/jpeg",
}
: null;
})
);
setInput(archive.prompt || "");
setDetailSections((prev) => ({
...prev,
required: true,
assets: true,
archives: true,
}));
setDetailSlotPickerIndex(null);
setTimeout(() => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.textContent = archive.prompt || "";
editableDiv.focus();
}
}, 0);
toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)");
}, []);
const handleDetailGeneration = useCallback(async () => {
if (isDetailGenerating) return;
@ -2244,6 +2490,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const detailPrompt =
userText ||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}`;
const archiveSnapshot = {
prompt: detailPrompt,
brief: { ...detailBrief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = detailSlots[index];
return slot
? {
url: slot.url,
name: slot.name,
contentType: slot.contentType,
}
: null;
}),
};
const detailCount = extractDetailFrameCount(detailPrompt);
const assistantMessageId = generateUUID();
const skillLogicPreview = [
@ -2571,6 +2831,14 @@ 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,
});
toast.success(`AI详情生成完成,共 ${generatedImages.length} 张`);
} catch (error) {
@ -2603,12 +2871,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [
addNode,
clearSelection,
detailBrief,
detailSlots,
imageParams.negativePrompt,
input,
isDetailGenerating,
normalizedDetailBrief,
selectedModelId,
saveDetailArchiveSnapshot,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
siteSettings?.canvasDefaultModelId,
@ -3850,10 +4120,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{DETAIL_SLOT_CONFIGS.map((config, index) => {
const slotAttachment = detailSlots[index];
const isUploading = detailSlotUploading === index;
const isPickingFromCanvas = detailSlotPickerIndex === index;
return (
<div
key={config.key}
className="group relative h-28 overflow-hidden rounded-xl border border-black/15 bg-white"
className={cn(
"group relative h-28 overflow-hidden rounded-xl border bg-white",
isPickingFromCanvas
? "border-blue-500 ring-2 ring-blue-200"
: "border-black/15"
)}
>
<input
type="file"
@ -3882,6 +4158,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-0.5 text-center text-[10px] text-white">
{config.label}
</div>
<button
type="button"
className={cn(
"absolute left-16 top-1 rounded px-1.5 py-0.5 text-[10px] text-white transition-opacity",
isPickingFromCanvas
? "bg-blue-600 opacity-100"
: "bg-sky-700/80 opacity-0 group-hover:opacity-100"
)}
onClick={() => activateDetailSlotCanvasPicker(index)}
>
画布
</button>
<button
type="button"
className="absolute left-1 top-1 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
@ -3898,19 +4186,32 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</button>
</>
) : (
<button
type="button"
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-1 text-[11px] text-black/65"
onClick={() => detailSlotInputRefs.current[index]?.click()}
disabled={isSubmitting || isUploading}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<span>{config.label}</span>
</button>
<>
<button
type="button"
className={cn(
"absolute right-1 top-1 rounded px-1.5 py-0.5 text-[10px] text-white",
isPickingFromCanvas ? "bg-blue-600" : "bg-sky-700/85"
)}
onClick={() => activateDetailSlotCanvasPicker(index)}
disabled={isSubmitting || isUploading}
>
画布
</button>
<button
type="button"
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-1 text-[11px] text-black/65"
onClick={() => detailSlotInputRefs.current[index]?.click()}
disabled={isSubmitting || isUploading}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<span>{config.label}</span>
</button>
</>
)}
</div>
);
@ -3919,6 +4220,64 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<div className="mt-2 text-[11px] text-black/55">
当前素材:产品图 {detailProductCount}/5,模特图 {detailModelReady ? "1/1" : "0/1"},搭配图 {detailBundleCount}/3(可选)
</div>
{detailSlotPickerIndex !== null ? (
<div className="mt-1 text-[11px] text-blue-700">
当前等待画布选图:{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}。请在画布中选中图片,系统会自动填入。
</div>
) : null}
</div>
) : null}
</div>
<div className="rounded-xl border border-black/10 bg-white">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
onClick={() => toggleDetailSection("archives")}
>
<div>
<p className="text-xs font-semibold text-black">详情项目存档</p>
<p className="text-[11px] text-black/55">历史输入与素材可一键回填,继续修改并重新生成</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-black/55 transition-transform",
detailSections.archives && "rotate-180"
)}
/>
</button>
{detailSections.archives ? (
<div className="border-t border-black/10 p-3">
{detailArchives.length === 0 ? (
<div className="rounded-lg border border-dashed border-black/15 px-3 py-2 text-[11px] text-black/45">
暂无存档。每次执行 AI详情生成成功后会自动保存为新存档。
</div>
) : (
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
{detailArchives.map((archive) => (
<div
key={archive.id}
className="flex items-center justify-between gap-3 rounded-lg border border-black/10 bg-white px-2.5 py-2"
>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-black">
{archive.title}
</div>
<div className="mt-0.5 text-[10px] text-black/45">
{new Date(archive.createdAt).toLocaleString()} · {archive.imageCount || 0}张输出
</div>
</div>
<button
type="button"
className="shrink-0 rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-[11px] text-blue-700 hover:bg-blue-100"
onClick={() => loadDetailArchive(archive)}
>
加载
</button>
</div>
))}
</div>
)}
</div>
) : null}
</div>