diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8befb66b5..ba923a4ca 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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; + 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(null); + const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState(null); + const [detailArchives, setDetailArchives] = useState([]); const [detailBrief, setDetailBrief] = useState({ 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) => { 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; + }) => { + 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 (
{config.label}
+ + <> + + + )} ); @@ -3919,6 +4220,64 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
当前素材:产品图 {detailProductCount}/5,模特图 {detailModelReady ? "1/1" : "0/1"},搭配图 {detailBundleCount}/3(可选)
+ {detailSlotPickerIndex !== null ? ( +
+ 当前等待画布选图:{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}。请在画布中选中图片,系统会自动填入。 +
+ ) : null} + + ) : null} + + +
+ + {detailSections.archives ? ( +
+ {detailArchives.length === 0 ? ( +
+ 暂无存档。每次执行 AI详情生成成功后会自动保存为新存档。 +
+ ) : ( +
+ {detailArchives.map((archive) => ( +
+
+
+ {archive.title} +
+
+ {new Date(archive.createdAt).toLocaleString()} · {archive.imageCount || 0}张输出 +
+
+ +
+ ))} +
+ )}
) : null}