feat detail mode: canvas slot picking and local archive load/regenerate flow
This commit is contained in:
parent
9af7619b7f
commit
2332a5d6c8
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user