feat: expand agent workflow and project management
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-03-19 18:33:42 +08:00
parent af7e98a4dc
commit 8d2066a3bf
23 changed files with 3098 additions and 777 deletions

View File

@ -8,6 +8,7 @@ import {
getSystemSetting,
updateAgentPreset,
} from "@/lib/db/queries";
import { isAgentWorkflowAdminType } from "@/lib/middleware/admin";
import {
AGENT_SECONDARY_TYPE_CONFIG_KEY,
isValidSecondaryTypeForPresetType,
@ -31,6 +32,14 @@ const updateAgentSchema = z
visibility: z.enum(["public", "private"]).optional(),
categoryPath: z
.object({
sectionId: z.string().trim().max(64).optional(),
sectionName: z.string().trim().max(64).optional(),
level1Id: z.string().trim().max(64).optional(),
level1Name: z.string().trim().max(64).optional(),
level2Id: z.string().trim().max(64).optional(),
level2Name: z.string().trim().max(64).optional(),
level3Id: z.string().trim().max(64).optional(),
level3Name: z.string().trim().max(64).optional(),
productCategory: z.string().trim().max(64).optional(),
audienceTag: z.string().trim().max(64).optional(),
usageScenario: z.string().trim().max(64).optional(),
@ -99,6 +108,11 @@ export async function PUT(
if (!existingPreset) {
return NextResponse.json({ success: false, error: "智能体不存在" }, { status: 404 });
}
const canManagePublicPreset =
isAgentWorkflowAdminType(session.user.type) && existingPreset.visibility === "public";
if (!canManagePublicPreset && existingPreset.ownerUserId !== session.user.id) {
return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
}
const effectiveType = data.type || existingPreset.type;
const effectiveSecondaryType =
@ -124,7 +138,7 @@ export async function PUT(
const preset = await updateAgentPreset({
id,
userId: session.user.id,
userId: canManagePublicPreset ? existingPreset.ownerUserId : session.user.id,
name: data.name,
type: data.type,
secondaryType: data.secondaryType ?? undefined,
@ -167,11 +181,25 @@ export async function DELETE(
}
const { id } = await params;
const deleted = await deleteAgentPresetById({
const existingPreset = await getAccessibleAgentPresetById({
id,
userId: session.user.id,
});
if (!existingPreset) {
return NextResponse.json({ success: false, error: "智能体不存在" }, { status: 404 });
}
const canManagePublicPreset =
isAgentWorkflowAdminType(session.user.type) && existingPreset.visibility === "public";
if (!canManagePublicPreset && existingPreset.ownerUserId !== session.user.id) {
return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
}
const deleted = await deleteAgentPresetById({
id,
userId: canManagePublicPreset ? existingPreset.ownerUserId : session.user.id,
});
if (!deleted) {
return NextResponse.json({ success: false, error: "智能体不存在" }, { status: 404 });
}

View File

@ -16,6 +16,10 @@ import {
const listQuerySchema = z.object({
scope: z.enum(["all", "public", "private"]).default("all"),
type: z.enum(["image", "video", "text"]).optional(),
sectionId: z.string().trim().max(64).optional(),
level1Id: z.string().trim().max(64).optional(),
level2Id: z.string().trim().max(64).optional(),
level3Id: z.string().trim().max(64).optional(),
productCategory: z.string().trim().max(64).optional(),
audienceTag: z.string().trim().max(64).optional(),
usageScenario: z.string().trim().max(64).optional(),
@ -41,6 +45,14 @@ const createAgentSchema = z
visibility: z.enum(["public", "private"]).default("private"),
categoryPath: z
.object({
sectionId: z.string().trim().max(64).optional(),
sectionName: z.string().trim().max(64).optional(),
level1Id: z.string().trim().max(64).optional(),
level1Name: z.string().trim().max(64).optional(),
level2Id: z.string().trim().max(64).optional(),
level2Name: z.string().trim().max(64).optional(),
level3Id: z.string().trim().max(64).optional(),
level3Name: z.string().trim().max(64).optional(),
productCategory: z.string().trim().max(64).optional(),
audienceTag: z.string().trim().max(64).optional(),
usageScenario: z.string().trim().max(64).optional(),
@ -65,6 +77,10 @@ export async function GET(request: NextRequest) {
const query = listQuerySchema.parse({
scope: searchParams.get("scope") || "all",
type: searchParams.get("type") || undefined,
sectionId: searchParams.get("sectionId") || undefined,
level1Id: searchParams.get("level1Id") || undefined,
level2Id: searchParams.get("level2Id") || undefined,
level3Id: searchParams.get("level3Id") || undefined,
productCategory: searchParams.get("productCategory") || undefined,
audienceTag: searchParams.get("audienceTag") || undefined,
usageScenario: searchParams.get("usageScenario") || undefined,
@ -84,6 +100,10 @@ export async function GET(request: NextRequest) {
const filterByPath = (items: typeof presets) =>
items.filter((item) => {
const path = item.categoryPath || {};
if (query.sectionId && path.sectionId !== query.sectionId) return false;
if (query.level1Id && path.level1Id !== query.level1Id) return false;
if (query.level2Id && path.level2Id !== query.level2Id) return false;
if (query.level3Id && path.level3Id !== query.level3Id) return false;
if (query.productCategory && path.productCategory !== query.productCategory) return false;
if (query.audienceTag && path.audienceTag !== query.audienceTag) return false;
if (query.usageScenario && path.usageScenario !== query.usageScenario) return false;

View File

@ -51,6 +51,11 @@ import {
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
defaultAgentPresetLibraryConfig,
normalizeAgentPresetLibraryConfig,
} from "@/lib/agents/preset-library-config";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
@ -235,6 +240,7 @@ export async function GET() {
realtimeCanvasConfigSetting,
storageFailoverConfigSetting,
ossImageMigrationConfigSetting,
agentPresetLibraryConfigSetting,
agentCategoryTreeConfigSetting,
agentSecondaryTypeConfigSetting,
agentWorkflowUiConfigSetting,
@ -339,6 +345,12 @@ export async function GET() {
.where(eq(systemSettings.key, OSS_MIGRATION_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PRESET_LIBRARY_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -410,6 +422,9 @@ export async function GET() {
const ossImageMigrationConfig = normalizeOssImageMigrationConfig(
ossImageMigrationConfigSetting?.value
);
const agentPresetLibraryConfig = normalizeAgentPresetLibraryConfig(
agentPresetLibraryConfigSetting?.value
);
const agentCategoryTreeConfig = normalizeAgentCategoryTreeConfig(
agentCategoryTreeConfigSetting?.value
);
@ -455,6 +470,7 @@ export async function GET() {
rankingEnabled: true,
canvasEnabled: true,
agentEnabled: true,
agentPresetLibraryConfig: defaultAgentPresetLibraryConfig,
agentCategoryTreeConfig: normalizeAgentCategoryTreeConfig(undefined),
agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig,
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
@ -502,6 +518,7 @@ export async function GET() {
lovartComposerPreviewTexts,
lovartPromptTagVisibility,
contactConfig,
agentPresetLibraryConfig,
agentCategoryTreeConfig,
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
@ -556,6 +573,59 @@ const updateSchema = z.object({
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
agentEnabled: z.boolean().optional(),
agentPresetLibraryConfig: z
.object({
sections: z
.array(
z.object({
id: z.string().trim().min(1).max(64).optional(),
label: z.string().trim().min(1).max(64),
description: z.string().trim().max(200).nullable().optional(),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
categories: z
.array(
z.lazy(() =>
z.object({
id: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().min(1).max(64),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
children: z
.array(
z.lazy(() =>
z.object({
id: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().min(1).max(64),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
children: z
.array(
z.object({
id: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().min(1).max(64),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
children: z.array(z.any()).optional(),
})
)
.max(100)
.optional(),
})
)
)
.max(100)
.optional(),
})
)
)
.max(100)
.optional(),
})
)
.max(30),
})
.optional(),
agentCategoryTreeConfig: z
.object({
productCategories: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
@ -577,6 +647,16 @@ const updateSchema = z.object({
.object({
brands: z.array(z.string().trim().min(1).max(64)).max(300).optional(),
stores: z.array(z.string().trim().min(1).max(64)).max(300).optional(),
brandStoreEntries: z
.array(
z.object({
brandName: z.string().trim().min(1).max(64),
stores: z.array(z.string().trim().min(1).max(64)).max(300).optional(),
sortOrder: z.number().min(0).max(999).optional(),
})
)
.max(300)
.optional(),
})
.optional(),
steps: z
@ -589,15 +669,18 @@ const updateSchema = z.object({
sortOrder: z.number().min(0).max(999).optional(),
options: z
.array(
z.object({
key: z.string().trim().min(1).max(64),
buttonName: z.string().trim().min(1).max(64),
description: z.string().trim().max(300).nullable().optional(),
previewImageUrl: z.string().trim().max(1000).nullable().optional(),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
promptFragment: z.string().trim().max(1000).nullable().optional(),
})
z.lazy(() =>
z.object({
key: z.string().trim().min(1).max(64),
buttonName: z.string().trim().min(1).max(64),
description: z.string().trim().max(300).nullable().optional(),
previewImageUrl: z.string().trim().max(1000).nullable().optional(),
enabled: z.boolean().optional(),
sortOrder: z.number().min(0).max(999).optional(),
promptFragment: z.string().trim().max(1000).nullable().optional(),
children: z.array(z.any()).max(100).optional(),
})
)
)
.max(100),
})
@ -719,6 +802,7 @@ export async function PUT(request: NextRequest) {
const body = await request.json();
if (!isAdmin) {
const allowedKeys = new Set([
"agentPresetLibraryConfig",
"agentWorkflowUiConfig",
"agentWorkflowMaterialApiConfig",
"agentWorkflowBrandModelConfig",
@ -738,6 +822,7 @@ export async function PUT(request: NextRequest) {
lovartComposerPreviewTexts,
lovartPromptTagVisibility,
contactConfig,
agentPresetLibraryConfig,
agentCategoryTreeConfig,
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
@ -908,6 +993,26 @@ export async function PUT(request: NextRequest) {
});
}
if (agentPresetLibraryConfig !== undefined) {
const normalized = normalizeAgentPresetLibraryConfig(agentPresetLibraryConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_PRESET_LIBRARY_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (agentCategoryTreeConfig !== undefined) {
const normalized = normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig);
await db
@ -1196,6 +1301,10 @@ export async function PUT(request: NextRequest) {
contactConfig !== undefined
? normalizeContactConfig(contactConfig)
: normalizeContactConfig(undefined),
agentPresetLibraryConfig:
agentPresetLibraryConfig !== undefined
? normalizeAgentPresetLibraryConfig(agentPresetLibraryConfig)
: defaultAgentPresetLibraryConfig,
agentCategoryTreeConfig:
agentCategoryTreeConfig !== undefined
? normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig)
@ -1428,6 +1537,26 @@ export async function PUT(request: NextRequest) {
});
}
if (agentPresetLibraryConfig !== undefined) {
const normalized = normalizeAgentPresetLibraryConfig(agentPresetLibraryConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_PRESET_LIBRARY_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (agentCategoryTreeConfig !== undefined) {
const normalized = normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig);
await db
@ -1716,6 +1845,10 @@ export async function PUT(request: NextRequest) {
contactConfig !== undefined
? normalizeContactConfig(contactConfig)
: normalizeContactConfig(undefined),
agentPresetLibraryConfig:
agentPresetLibraryConfig !== undefined
? normalizeAgentPresetLibraryConfig(agentPresetLibraryConfig)
: defaultAgentPresetLibraryConfig,
agentCategoryTreeConfig:
agentCategoryTreeConfig !== undefined
? normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig)

View File

@ -249,6 +249,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [agentProjectResultUrls, setAgentProjectResultUrls] = useState<string[]>([]);
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false);
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
"pending" | "project" | "local" | "empty"
>("pending");
@ -597,11 +598,17 @@ export default function CanvasEditorPage({ params }: PageProps) {
]);
const persistAgentProject = useCallback(
async (reason: "autosave" | "manual" | "switch") => {
async (
reason: "autosave" | "manual" | "switch",
overrides?: { title?: string | null }
) => {
if (!isAgentMode || !agentProjectId) return false;
const nextTitle =
String(overrides?.title ?? agentProjectTitle ?? "").trim() ||
buildDefaultAgentProjectTitle();
const result = await agentProjectService.updateProject(agentProjectId, {
title: agentProjectTitle || buildDefaultAgentProjectTitle(),
title: nextTitle,
thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(),
});
@ -752,9 +759,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
toast.error("当前还没有可保存的生图项目");
return;
}
const suggestedTitle =
String(agentProjectTitle || "").trim() || buildDefaultAgentProjectTitle();
const promptedTitle = window.prompt("请输入要保存的项目名称", suggestedTitle);
if (promptedTitle === null) {
return;
}
const nextTitle = promptedTitle.trim();
if (!nextTitle) {
toast.error("项目名称不能为空");
return;
}
setIsAgentProjectSaving(true);
try {
const success = await persistAgentProject("manual");
const success = await persistAgentProject("manual", { title: nextTitle });
if (success) {
toast.success("生图项目已保存");
} else {
@ -765,6 +785,36 @@ export default function CanvasEditorPage({ params }: PageProps) {
}
}, [agentProjectId, persistAgentProject]);
const handleRenameAgentProject = useCallback(
async (targetProjectId: string, currentTitle?: string | null) => {
const suggestedTitle =
String(currentTitle || "").trim() || "未命名生图项目";
const promptedTitle = window.prompt("请输入新的项目名称", suggestedTitle);
if (promptedTitle === null) {
return;
}
const nextTitle = promptedTitle.trim();
if (!nextTitle) {
toast.error("项目名称不能为空");
return;
}
const result = await agentProjectService.updateProject(targetProjectId, {
title: nextTitle,
});
if (!result.success || !result.data) {
toast.error(result.error || "更新生图项目名称失败");
return;
}
if (targetProjectId === agentProjectId) {
setAgentProjectTitle(result.data.title || nextTitle);
}
toast.success("项目名称已更新");
},
[agentProjectId]
);
const handleCreateAgentProject = useCallback(async () => {
if (agentProjectId) {
await persistAgentProject("switch");
@ -1357,28 +1407,58 @@ export default function CanvasEditorPage({ params }: PageProps) {
const agentProjectNavSlot = isAgentMode ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
void handleManualSaveAgentProject();
}}
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
{isAgentProjectSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
保存项目
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={isAgentProjectLoading}
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
{isAgentProjectSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
项目管理
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 border-white/10 bg-[#0b0d0f] text-white"
>
<DropdownMenuItem
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
void handleManualSaveAgentProject();
}}
>
<Save className="mr-2 h-4 w-4" />
保存项目
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
setIsAgentProjectSheetOpen(true);
}}
>
<Grid3X3 className="mr-2 h-4 w-4" />
项目列表
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AgentProjectListSheet
open={isAgentProjectSheetOpen}
onOpenChange={setIsAgentProjectSheetOpen}
currentProjectId={agentProjectId || null}
currentProjectTitle={agentProjectTitle}
onCreateProject={handleCreateAgentProject}
onSelectProject={handleSelectAgentProject}
onDeleteProject={handleDeleteAgentProject}
onRenameProject={handleRenameAgentProject}
/>
</div>
) : null;

View File

@ -156,7 +156,6 @@ type ToolStepKey =
| "preset"
| "brand-model"
| "materials"
| "slots"
| "prompt"
| "settings";
@ -808,15 +807,6 @@ export function AgentGenerationWorkspaceDarkV2({
},
]
: []),
{
key: "slots" as const,
label: "图片槽位",
description: "检查和补齐参考图槽位",
icon: ImagePlus,
disabled: !selectedAgentPresetId,
completed: hasSatisfiedSlotRequirement,
optional: false,
},
{
key: "prompt" as const,
label: "补充要求",
@ -956,11 +946,11 @@ export function AgentGenerationWorkspaceDarkV2({
if (current !== "preset") return current;
if (isDetailReplacementPreset || isSkuReplacementPreset) {
if (hasMaterialStep) return "materials";
return "slots";
return "prompt";
}
if (hasBrandModelStep) return "brand-model";
if (hasMaterialStep) return "materials";
return "slots";
return "prompt";
});
}, [
hasBrandModelStep,
@ -1481,6 +1471,179 @@ export function AgentGenerationWorkspaceDarkV2({
}));
}, [updateSkuReplacementState]);
const attachmentSlotPanel = (
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
输入图槽位
</h3>
<div className="mb-4 rounded-[20px] border border-emerald-400/15 bg-emerald-500/[0.06] px-4 py-3 text-xs leading-6 text-emerald-100/80">
上一步已选的白底图会自动带入对应产品槽位,这里只作为“检查 / 替换 / 微调映射”的调整区,不再单独占一个步骤页。
</div>
{agentUploadSlots.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{agentUploadSlots.map((slot, index) => {
const isActiveAssignmentSlot = activeAssignmentSlotIndex === index;
return (
<motion.div
key={slot.label}
whileHover={{ scale: 1.02 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-[24px] transition-all",
attachments[index]
? "ring-2 ring-emerald-400/45"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35",
isActiveAssignmentSlot && "ring-2 ring-sky-400/65 ring-offset-2 ring-offset-black"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white transition-opacity hover:bg-white/30"
onClick={() => updateAttachmentAtSlot(index, null)}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-2 p-2">
<button
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/80 transition-colors hover:border-white/20 hover:text-white"
>
{attachments[index] ? "更换" : "上传"}
</button>
<button
type="button"
onClick={() => setActiveAssignmentSlotIndex(index)}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
isActiveAssignmentSlot
? "border-sky-400/45 bg-sky-500/[0.18] text-sky-100"
: "border-white/10 bg-black/45 text-white/70 hover:border-white/20 hover:text-white"
)}
>
{isActiveAssignmentSlot ? "当前目标" : "设为目标"}
</button>
</div>
</motion.div>
);
})}
</div>
{slotSourceLibrary.length > 0 ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-3">
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-medium text-white">从已选素材快速替换槽位</div>
<div className="text-xs text-white/42">
先点击上方槽位的“设为目标”,再点击下方素材图,将它放入对应槽位。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/60">
当前目标:
{activeAssignmentSlotIndex !== null
? agentUploadSlots[activeAssignmentSlotIndex]?.label || "未选择"
: "未选择"}
</div>
</div>
{assignableSourceLibrary.length > 0 ? (
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{assignableSourceLibrary.map((source) => {
const assignedToActiveSlot =
activeAssignmentSlotIndex !== null &&
attachments[activeAssignmentSlotIndex]?.url === source.url;
return (
<button
key={source.id}
type="button"
onClick={() => {
if (activeAssignmentSlotIndex === null) return;
handleAssignSourceToSlot(activeAssignmentSlotIndex, source);
}}
disabled={activeAssignmentSlotIndex === null}
className={cn(
"overflow-hidden rounded-[20px] border text-left transition-all",
assignedToActiveSlot
? "border-emerald-400/55 bg-emerald-500/[0.08]"
: "border-white/10 bg-white/[0.03] hover:border-white/18",
activeAssignmentSlotIndex === null && "cursor-not-allowed opacity-45"
)}
>
<div className="relative aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(source.url, 320)}
alt={source.label}
className="h-full w-full object-cover"
/>
<div className="absolute left-2 top-2 rounded-full bg-black/60 px-2 py-1 text-[10px] text-white">
{source.sourceLabel}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium text-white/88">
{source.label}
</div>
<div className="truncate text-[11px] text-white/42">
{source.name}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[92px] items-center justify-center rounded-[20px] border border-dashed border-white/10 bg-white/[0.02] px-4 text-center text-xs text-white/40">
{activeAssignmentIsModelSlot
? "当前模特槽位还没有可分配的品牌模特图,请先在“品牌模特”步骤里选择。"
: "当前产品槽位还没有来自已选素材区的图片,请先到“SKU / SPU 选图”里完成选择。"}
</div>
)}
</div>
) : null}
</div>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</div>
)}
</div>
);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-black">
<AgentCanvasTopNavDark
@ -1576,176 +1739,6 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
) : null}
<div className={cn(activeToolStep === "slots" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-emerald-300" />
图片槽位
</h3>
{agentUploadSlots.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{agentUploadSlots.map((slot, index) => {
const isActiveAssignmentSlot = activeAssignmentSlotIndex === index;
return (
<motion.div
key={slot.label}
whileHover={{ scale: 1.02 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-[24px] transition-all",
attachments[index]
? "ring-2 ring-emerald-400/45"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35",
isActiveAssignmentSlot && "ring-2 ring-sky-400/65 ring-offset-2 ring-offset-black"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white transition-opacity hover:bg-white/30"
onClick={() => updateAttachmentAtSlot(index, null)}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-400" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-2 p-2">
<button
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
className="rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/80 transition-colors hover:border-white/20 hover:text-white"
>
{attachments[index] ? "更换" : "上传"}
</button>
<button
type="button"
onClick={() => setActiveAssignmentSlotIndex(index)}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] transition-colors",
isActiveAssignmentSlot
? "border-sky-400/45 bg-sky-500/[0.18] text-sky-100"
: "border-white/10 bg-black/45 text-white/70 hover:border-white/20 hover:text-white"
)}
>
{isActiveAssignmentSlot ? "当前目标" : "设为目标"}
</button>
</div>
</motion.div>
);
})}
</div>
{slotSourceLibrary.length > 0 ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-3">
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-medium text-white">从第一步已选素材填入槽位</div>
<div className="text-xs text-white/42">
先点击上方槽位的“设为目标”,再点击下方素材图,将它放入对应槽位。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/60">
当前目标:
{activeAssignmentSlotIndex !== null
? agentUploadSlots[activeAssignmentSlotIndex]?.label || "未选择"
: "未选择"}
</div>
</div>
{assignableSourceLibrary.length > 0 ? (
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{assignableSourceLibrary.map((source) => {
const assignedToActiveSlot =
activeAssignmentSlotIndex !== null &&
attachments[activeAssignmentSlotIndex]?.url === source.url;
return (
<button
key={source.id}
type="button"
onClick={() => {
if (activeAssignmentSlotIndex === null) return;
handleAssignSourceToSlot(activeAssignmentSlotIndex, source);
}}
disabled={activeAssignmentSlotIndex === null}
className={cn(
"overflow-hidden rounded-[20px] border text-left transition-all",
assignedToActiveSlot
? "border-emerald-400/55 bg-emerald-500/[0.08]"
: "border-white/10 bg-white/[0.03] hover:border-white/18",
activeAssignmentSlotIndex === null && "cursor-not-allowed opacity-45"
)}
>
<div className="relative aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(source.url, 320)}
alt={source.label}
className="h-full w-full object-cover"
/>
<div className="absolute left-2 top-2 rounded-full bg-black/60 px-2 py-1 text-[10px] text-white">
{source.sourceLabel}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium text-white/88">
{source.label}
</div>
<div className="truncate text-[11px] text-white/42">
{source.name}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[92px] items-center justify-center rounded-[20px] border border-dashed border-white/10 bg-white/[0.02] px-4 text-center text-xs text-white/40">
{activeAssignmentIsModelSlot
? "当前模特槽位还没有可分配的品牌模特图,请先在“品牌模特”步骤里选择。"
: "当前产品槽位还没有来自第一步的已选商品图,请先到“SKU / SPU 选图”里完成选择。"}
</div>
)}
</div>
) : null}
</div>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会按模板配置展示对应的产品图和模特图槽位。
</div>
)}
</div>
</div>
<div className={cn(activeToolStep === "prompt" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
@ -1762,7 +1755,9 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
<div className={cn(activeToolStep === "settings" ? "block" : "hidden")}>
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<div className="space-y-4">
{attachmentSlotPanel}
<div className="rounded-[28px] border border-white/12 bg-white/[0.02] p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-emerald-300" />
生成参数
@ -1835,6 +1830,7 @@ export function AgentGenerationWorkspaceDarkV2({
</Button>
</div>
</div>
</div>
</div>
</div>
@ -1997,7 +1993,7 @@ export function AgentGenerationWorkspaceDarkV2({
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
先在上方“图片槽位”中选择用于详情替换的白底输入图。
请先在“生成参数”里的输入图槽位中确认用于详情替换的白底输入图。
</div>
)}
@ -2304,7 +2300,7 @@ export function AgentGenerationWorkspaceDarkV2({
))
) : (
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
先在上方“图片槽位”中选择用于 SKU 替换的白底输入图。
请先在“生成参数”里的输入图槽位中确认用于 SKU 替换的白底输入图。
</div>
)}

View File

@ -82,6 +82,9 @@ export function AgentPresetCardDark({
: imageModeType === "scene_generation"
? "场景图"
: null;
const showEdit = Boolean(onEdit);
const showDelete = Boolean(onDelete);
const showFork = Boolean(onFork) && !showEdit && !showDelete;
if (compact) {
return (
@ -103,6 +106,45 @@ export function AgentPresetCardDark({
)}
onClick={() => onSelect(preset.id)}
>
<div className="absolute right-2 top-2 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{showEdit ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Pencil className="h-3 w-3" />
</button>
) : null}
{showDelete ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-red-500/80 hover:text-white"
>
<Trash2 className="h-3 w-3" />
</button>
) : null}
{showFork ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFork?.(preset);
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
<div className="relative mx-auto h-[70px] w-[70px] overflow-hidden rounded-2xl border border-white/10 bg-black/30">
{previewUrl ? (
<img
@ -237,36 +279,36 @@ export function AgentPresetCardDark({
whileHover={{ opacity: 1 }}
>
<div className="flex flex-col gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{preset.visibility === "private" && onEdit && (
{showEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(preset);
onEdit?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>
<Pencil className="h-4 w-4" />
</button>
)}
{preset.visibility === "private" && onDelete && (
{showDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(preset);
onDelete?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-red-500/80 hover:text-white"
>
<Trash2 className="h-4 w-4" />
</button>
)}
{preset.visibility === "public" && onFork && (
{showFork && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFork(preset);
onFork?.(preset);
}}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white/80 backdrop-blur-md transition-all hover:bg-white/20 hover:text-white"
>

View File

@ -30,9 +30,17 @@ import {
normalizeAgentPresetModeConfig,
patchAgentDefaultParamsWithModeConfig,
} from "@/lib/agents/preset-mode-config";
import {
buildAgentPresetCategoryPath,
getEnabledAgentPresetLibraryNodes,
getEnabledAgentPresetLibrarySections,
normalizeAgentPresetLibraryConfig,
resolveAgentPresetCategoryPath,
} from "@/lib/agents/preset-library-config";
import { imageGenerationService } from "@/services/canvas";
import { agentService } from "@/services/agents";
import type {
AgentPresetLibraryConfig,
AgentCreatePayload,
AgentPresetModeType,
AgentPresetModeConfig,
@ -111,6 +119,9 @@ export function AgentPresetDialog({
isMultimodal?: boolean;
}>;
}>("/api/models", fetcher);
const { data: siteSettingsData } = useSWR<{
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
}>("/api/site-settings", fetcher);
const [name, setName] = useState("");
const [type, setType] = useState<"image" | "video" | "text">("image");
@ -127,6 +138,10 @@ export function AgentPresetDialog({
const [uploadingSlotIndex, setUploadingSlotIndex] = useState<number | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [slots, setSlots] = useState<DialogSlotState[]>([createDefaultDialogSlot()]);
const [selectedPresetSectionId, setSelectedPresetSectionId] = useState<string>("");
const [selectedPresetLevel1Id, setSelectedPresetLevel1Id] = useState<string>("");
const [selectedPresetLevel2Id, setSelectedPresetLevel2Id] = useState<string>("");
const [selectedPresetLevel3Id, setSelectedPresetLevel3Id] = useState<string>("");
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const isDark = theme === "dark";
@ -153,6 +168,50 @@ export function AgentPresetDialog({
...IMAGE_MODE_OPTIONS,
]
: IMAGE_MODE_OPTIONS;
const presetLibraryConfig = useMemo(
() => normalizeAgentPresetLibraryConfig(siteSettingsData?.agentPresetLibraryConfig),
[siteSettingsData?.agentPresetLibraryConfig]
);
const presetLibrarySections = useMemo(
() => getEnabledAgentPresetLibrarySections(presetLibraryConfig),
[presetLibraryConfig]
);
const selectedPresetSection = useMemo(
() =>
presetLibrarySections.find((section) => section.id === selectedPresetSectionId) ||
null,
[presetLibrarySections, selectedPresetSectionId]
);
const presetLevel1Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetSection?.categories || []),
[selectedPresetSection]
);
const selectedPresetLevel1 = useMemo(
() =>
presetLevel1Options.find((node) => node.id === selectedPresetLevel1Id) ||
null,
[presetLevel1Options, selectedPresetLevel1Id]
);
const presetLevel2Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetLevel1?.children || []),
[selectedPresetLevel1]
);
const selectedPresetLevel2 = useMemo(
() =>
presetLevel2Options.find((node) => node.id === selectedPresetLevel2Id) ||
null,
[presetLevel2Options, selectedPresetLevel2Id]
);
const presetLevel3Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetLevel2?.children || []),
[selectedPresetLevel2]
);
const selectedPresetLevel3 = useMemo(
() =>
presetLevel3Options.find((node) => node.id === selectedPresetLevel3Id) ||
null,
[presetLevel3Options, selectedPresetLevel3Id]
);
const videoProviderTypes = useMemo(
() => new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]),
@ -212,6 +271,10 @@ export function AgentPresetDialog({
modeType: "scene_generation",
});
setSlots([createDefaultDialogSlot()]);
setSelectedPresetSectionId("");
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
setUploadingSlotIndex(null);
};
@ -246,7 +309,15 @@ export function AgentPresetDialog({
})),
}))
);
}, [initialPreset, open]);
const resolvedCategoryPath = resolveAgentPresetCategoryPath({
categoryPath: initialPreset.categoryPath || null,
config: presetLibraryConfig,
});
setSelectedPresetSectionId(resolvedCategoryPath.sectionId || "");
setSelectedPresetLevel1Id(resolvedCategoryPath.level1Id || "");
setSelectedPresetLevel2Id(resolvedCategoryPath.level2Id || "");
setSelectedPresetLevel3Id(resolvedCategoryPath.level3Id || "");
}, [initialPreset, open, presetLibraryConfig]);
useEffect(() => {
if (type !== "image" || modeConfig.modeType === "legacy") {
@ -281,6 +352,48 @@ export function AgentPresetDialog({
});
}, [modeConfig.modeType, type]);
useEffect(() => {
if (
selectedPresetSectionId &&
!presetLibrarySections.some((section) => section.id === selectedPresetSectionId)
) {
setSelectedPresetSectionId("");
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLibrarySections, selectedPresetSectionId]);
useEffect(() => {
if (
selectedPresetLevel1Id &&
!presetLevel1Options.some((node) => node.id === selectedPresetLevel1Id)
) {
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLevel1Options, selectedPresetLevel1Id]);
useEffect(() => {
if (
selectedPresetLevel2Id &&
!presetLevel2Options.some((node) => node.id === selectedPresetLevel2Id)
) {
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLevel2Options, selectedPresetLevel2Id]);
useEffect(() => {
if (
selectedPresetLevel3Id &&
!presetLevel3Options.some((node) => node.id === selectedPresetLevel3Id)
) {
setSelectedPresetLevel3Id("");
}
}, [presetLevel3Options, selectedPresetLevel3Id]);
const handleSlotChange = (slotIndex: number, updates: Partial<DialogSlotState>) => {
setSlots((current) =>
current.map((slot, index) =>
@ -432,10 +545,17 @@ export function AgentPresetDialog({
const nextDefaultParams = isStructuredImageModeBuilder
? patchAgentDefaultParamsWithModeConfig(initialPreset?.defaultParams || null, modeConfig)
: undefined;
const nextCategoryPath = buildAgentPresetCategoryPath({
section: selectedPresetSection,
level1: selectedPresetLevel1,
level2: selectedPresetLevel2,
level3: selectedPresetLevel3,
});
const basePayload: AgentCreatePayload = {
name: name.trim(),
type,
visibility,
categoryPath: nextCategoryPath,
description: description.trim() || undefined,
defaultModelId,
defaultParams: nextDefaultParams || undefined,
@ -455,7 +575,6 @@ export function AgentPresetDialog({
? await agentService.updatePreset(initialPreset.id, {
...basePayload,
secondaryType: null,
categoryPath: null,
} satisfies AgentUpdatePayload)
: await agentService.createPreset(basePayload);
@ -693,6 +812,184 @@ export function AgentPresetDialog({
</div>
</div>
{type === "image" ? (
<div
className={cn(
"rounded-xl border p-4",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className="mb-4">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
生图模式分类
</div>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
选择该生图模式显示在哪个分类页,以及对应的一级、二级、三级分类。
</div>
</div>
{presetLibrarySections.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>顶部分类页</div>
<Select
value={selectedPresetSectionId || "__none__"}
onValueChange={(value) => {
const nextSectionId = value === "__none__" ? "" : value;
setSelectedPresetSectionId(nextSectionId);
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择顶部分类页" />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不分类
</SelectItem>
{presetLibrarySections.map((section) => (
<SelectItem
key={section.id}
value={section.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{section.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>一级分类</div>
<Select
value={selectedPresetLevel1Id || "__none__"}
onValueChange={(value) => {
const nextValue = value === "__none__" ? "" : value;
setSelectedPresetLevel1Id(nextValue);
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}}
disabled={!selectedPresetSection || presetLevel1Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择一级分类" />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel1Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>二级分类</div>
<Select
value={selectedPresetLevel2Id || "__none__"}
onValueChange={(value) => {
const nextValue = value === "__none__" ? "" : value;
setSelectedPresetLevel2Id(nextValue);
setSelectedPresetLevel3Id("");
}}
disabled={!selectedPresetLevel1 || presetLevel2Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择二级分类" />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel2Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>三级分类</div>
<Select
value={selectedPresetLevel3Id || "__none__"}
onValueChange={(value) => {
setSelectedPresetLevel3Id(value === "__none__" ? "" : value);
}}
disabled={!selectedPresetLevel2 || presetLevel3Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择三级分类" />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel3Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className={cn("rounded-lg border border-dashed px-4 py-6 text-sm", isDark ? "border-white/10 bg-black/20 text-white/55" : "text-muted-foreground")}>
还没有配置任何生图分类,请先到“智能体工作流配置 - 生图分类”中新增顶部分类页和三级分类树。
</div>
)}
</div>
) : null}
{type === "image" ? (
<div
className={cn(

View File

@ -1,36 +1,39 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSession } from "next-auth/react";
import {
Loader2,
Plus,
Search,
Sparkles,
User,
Grid3X3,
Wand2,
LayoutGrid,
FolderOpen,
Building2,
Menu
} from "lucide-react";
import useSWR from "swr";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import type { AgentPreset, AgentPresetLibraryCategoryNode, AgentPresetLibraryConfig } from "@/types/agent";
import { normalizeAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import {
AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID,
AGENT_PRESET_LIBRARY_MY_WORKS_ID,
getEnabledAgentPresetLibraryNodes,
getEnabledAgentPresetLibrarySections,
normalizeAgentPresetLibraryConfig,
resolveAgentPresetCategoryPath,
} from "@/lib/agents/preset-library-config";
import { AgentWorkflowShowcaseBanner } from "./AgentWorkflowShowcaseBanner";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentPresetPanelDarkV2Props {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
@ -46,12 +49,19 @@ export function AgentPresetPanelDarkV2({
embedded = false,
embeddedEntityLabel = "智能体",
}: AgentPresetPanelDarkV2Props) {
const [activeTab, setActiveTab] = useState<NavTab>("cases");
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<string>(AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID);
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data: siteSettingsData } = useSWR<{ agentWorkflowShowcaseConfig?: unknown }>(
!embedded ? "/api/site-settings" : null,
const [selectedLevel1Id, setSelectedLevel1Id] = useState("");
const [selectedLevel2Id, setSelectedLevel2Id] = useState("");
const [selectedLevel3Id, setSelectedLevel3Id] = useState("");
const { data: siteSettingsData } = useSWR<{
agentWorkflowShowcaseConfig?: unknown;
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
}>(
"/api/site-settings",
(url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json())
);
@ -68,28 +78,124 @@ export function AgentPresetPanelDarkV2({
return result.data;
}
);
const presetLibraryConfig = useMemo(
() => normalizeAgentPresetLibraryConfig(siteSettingsData?.agentPresetLibraryConfig),
[siteSettingsData?.agentPresetLibraryConfig]
);
const presetLibrarySections = useMemo(
() => getEnabledAgentPresetLibrarySections(presetLibraryConfig),
[presetLibraryConfig]
);
const navTabs = useMemo(() => {
const dynamicTabs = presetLibrarySections.map((section) => ({
id: section.id,
label: section.label,
icon: LayoutGrid,
isMyWorks: false,
}));
const myWorksTab = {
id: AGENT_PRESET_LIBRARY_MY_WORKS_ID,
label: "我的作品",
icon: FolderOpen,
isMyWorks: true,
};
if (dynamicTabs.length === 0) {
return [myWorksTab];
}
return [dynamicTabs[0], myWorksTab, ...dynamicTabs.slice(1)];
}, [presetLibrarySections]);
const activeSection = useMemo(
() => presetLibrarySections.find((section) => section.id === activeTab) || null,
[activeTab, presetLibrarySections]
);
const level1Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(activeSection?.categories || []),
[activeSection]
);
const selectedLevel1 = useMemo(
() => level1Options.find((node) => node.id === selectedLevel1Id) || null,
[level1Options, selectedLevel1Id]
);
const level2Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedLevel1?.children || []),
[selectedLevel1]
);
const selectedLevel2 = useMemo(
() => level2Options.find((node) => node.id === selectedLevel2Id) || null,
[level2Options, selectedLevel2Id]
);
const level3Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedLevel2?.children || []),
[selectedLevel2]
);
const isMyWorksTab = activeTab === AGENT_PRESET_LIBRARY_MY_WORKS_ID;
const canManagePublicPresets =
session?.user?.type === "admin" || session?.user?.type === "agent_admin";
useEffect(() => {
if (navTabs.some((tab) => tab.id === activeTab)) return;
const nextTabId = navTabs[0]?.id || AGENT_PRESET_LIBRARY_MY_WORKS_ID;
setActiveTab(nextTabId);
}, [activeTab, navTabs]);
useEffect(() => {
setSelectedLevel1Id("");
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [activeTab]);
useEffect(() => {
if (!selectedLevel1Id || level1Options.some((node) => node.id === selectedLevel1Id)) return;
setSelectedLevel1Id("");
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [level1Options, selectedLevel1Id]);
useEffect(() => {
if (!selectedLevel2Id || level2Options.some((node) => node.id === selectedLevel2Id)) return;
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}, [level2Options, selectedLevel2Id]);
useEffect(() => {
if (!selectedLevel3Id || level3Options.some((node) => node.id === selectedLevel3Id)) return;
setSelectedLevel3Id("");
}, [level3Options, selectedLevel3Id]);
// 根据当前标签过滤数据
const filteredPresets = useMemo(() => {
let presets: AgentPreset[] = [];
switch (activeTab) {
case "cases":
presets = data?.public || [];
break;
case "my-works":
presets = data?.private || [];
break;
case "personal-assets":
// 这里可以接入个人资产库数据
presets = data?.private || [];
break;
case "company-assets":
// 这里可以接入公司资产库数据
presets = data?.public || [];
break;
let presets: AgentPreset[] = isMyWorksTab ? data?.private || [] : data?.public || [];
if (!isMyWorksTab) {
const fallbackSectionId =
presetLibrarySections.find((section) => section.id === AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID)?.id ||
presetLibrarySections[0]?.id ||
null;
presets = presets.filter((preset) => {
const resolvedPath = resolveAgentPresetCategoryPath({
categoryPath: preset.categoryPath || null,
config: presetLibraryConfig,
});
const presetSectionId = resolvedPath.sectionId || fallbackSectionId;
if (!presetSectionId || presetSectionId !== activeTab) {
return false;
}
if (selectedLevel1Id && resolvedPath.level1Id !== selectedLevel1Id) {
return false;
}
if (selectedLevel2Id && resolvedPath.level2Id !== selectedLevel2Id) {
return false;
}
if (selectedLevel3Id && resolvedPath.level3Id !== selectedLevel3Id) {
return false;
}
return true;
});
}
// 搜索过滤
if (keyword.trim()) {
const normalizedKeyword = keyword.trim().toLowerCase();
@ -101,7 +207,17 @@ export function AgentPresetPanelDarkV2({
}
return presets;
}, [data, activeTab, keyword]);
}, [
activeTab,
data,
isMyWorksTab,
keyword,
presetLibraryConfig,
presetLibrarySections,
selectedLevel1Id,
selectedLevel2Id,
selectedLevel3Id,
]);
const handleForkPreset = async (preset: AgentPreset) => {
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
@ -130,22 +246,10 @@ export function AgentPresetPanelDarkV2({
await mutate();
};
// 导航标签配置
const navTabs = [
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid },
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen },
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User },
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 },
];
// 获取当前标签标题
const getCurrentTitle = () => {
switch (activeTab) {
case "cases": return "公共智能体";
case "my-works": return "我的智能体";
case "personal-assets": return "个人资产库";
case "company-assets": return "公司资产库";
}
if (isMyWorksTab) return "我的作品";
return activeSection?.label || "生图模式";
};
// 嵌入模式只渲染卡片网格
@ -183,10 +287,10 @@ export function AgentPresetPanelDarkV2({
{/* 导航标签(简化版) */}
<div className="mb-4 flex flex-wrap gap-1">
{navTabs.slice(0, 2).map((tab) => (
{navTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as NavTab)}
onClick={() => setActiveTab(tab.id)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
activeTab === tab.id
@ -199,6 +303,40 @@ export function AgentPresetPanelDarkV2({
))}
</div>
{!isMyWorksTab ? (
<div className="mb-4 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-3">
<CategoryFilterRow
label="一级"
options={level1Options}
selectedId={selectedLevel1Id}
onSelect={(value) => {
setSelectedLevel1Id(value);
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}}
/>
{selectedLevel1 ? (
<CategoryFilterRow
label="二级"
options={level2Options}
selectedId={selectedLevel2Id}
onSelect={(value) => {
setSelectedLevel2Id(value);
setSelectedLevel3Id("");
}}
/>
) : null}
{selectedLevel2 ? (
<CategoryFilterRow
label="三级"
options={level3Options}
selectedId={selectedLevel3Id}
onSelect={setSelectedLevel3Id}
/>
) : null}
</div>
) : null}
{/* 卡片网格 */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2.5">
{isLoading ? (
@ -213,9 +351,17 @@ export function AgentPresetPanelDarkV2({
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
onFork={preset.visibility === "public" && !canManagePublicPresets ? handleForkPreset : undefined}
onEdit={
preset.visibility === "private" || canManagePublicPresets
? setEditingPreset
: undefined
}
onDelete={
preset.visibility === "private" || canManagePublicPresets
? handleDeletePreset
: undefined
}
index={index}
compact
/>
@ -352,7 +498,7 @@ export function AgentPresetPanelDarkV2({
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder="搜索智能体..."
placeholder="搜索生图模式..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
@ -363,11 +509,50 @@ export function AgentPresetPanelDarkV2({
)}
/>
</div>
<div className="flex items-center gap-2 text-sm text-white/50">
<Grid3X3 className="h-4 w-4" />
<span>共 {filteredPresets.length} 个</span>
</div>
</motion.div>
<div className="flex items-center gap-2 text-sm text-white/50">
<Grid3X3 className="h-4 w-4" />
<span>共 {filteredPresets.length} 个</span>
</div>
</motion.div>
{!isMyWorksTab ? (
<motion.div
className="mb-6 space-y-2 rounded-2xl border border-white/8 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.35 }}
>
<CategoryFilterRow
label="一级分类"
options={level1Options}
selectedId={selectedLevel1Id}
onSelect={(value) => {
setSelectedLevel1Id(value);
setSelectedLevel2Id("");
setSelectedLevel3Id("");
}}
/>
{selectedLevel1 ? (
<CategoryFilterRow
label="二级分类"
options={level2Options}
selectedId={selectedLevel2Id}
onSelect={(value) => {
setSelectedLevel2Id(value);
setSelectedLevel3Id("");
}}
/>
) : null}
{selectedLevel2 ? (
<CategoryFilterRow
label="三级分类"
options={level3Options}
selectedId={selectedLevel3Id}
onSelect={setSelectedLevel3Id}
/>
) : null}
</motion.div>
) : null}
{/* 区域标题 */}
<motion.div
@ -396,9 +581,17 @@ export function AgentPresetPanelDarkV2({
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
onFork={preset.visibility === "public" && !canManagePublicPresets ? handleForkPreset : undefined}
onEdit={
preset.visibility === "private" || canManagePublicPresets
? setEditingPreset
: undefined
}
onDelete={
preset.visibility === "private" || canManagePublicPresets
? handleDeletePreset
: undefined
}
index={index}
/>
))
@ -432,7 +625,7 @@ export function AgentPresetPanelDarkV2({
setShowCreateDialog(true);
}}
initialPreset={editingPreset}
entityLabel="智能体"
entityLabel="生图模式"
theme="dark"
onSaved={async (preset) => {
await mutate();
@ -459,3 +652,62 @@ function EmptyState({ message }: { message: string }) {
</motion.div>
);
}
function CategoryFilterRow({
label,
options,
selectedId,
onSelect,
dark = true,
}: {
label: string;
options: AgentPresetLibraryCategoryNode[];
selectedId?: string;
onSelect: (value: string) => void;
dark?: boolean;
}) {
if (!options.length) return null;
return (
<div className="flex flex-wrap items-center gap-2">
<span className={cn("text-xs", dark ? "text-white/38" : "text-muted-foreground")}>
{label}
</span>
<button
type="button"
onClick={() => onSelect("")}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
!selectedId
? dark
? "border-white/30 bg-white text-black"
: "border-primary bg-primary/10 text-primary"
: dark
? "border-white/10 bg-white/[0.03] text-white/62 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground"
)}
>
全部
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onSelect(option.id)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
selectedId === option.id
? dark
? "border-emerald-300/35 bg-emerald-300/90 text-black"
: "border-primary bg-primary/10 text-primary"
: dark
? "border-white/10 bg-white/[0.03] text-white/62 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground"
)}
>
{option.name}
</button>
))}
</div>
);
}

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { FolderOpen, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { agentProjectService } from "@/services/agent-projects";
import { Button } from "@/components/ui/button";
@ -14,31 +14,34 @@ import {
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { AgentGenerationProject } from "@/types/agent";
interface AgentProjectListSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentProjectId?: string | null;
currentProjectTitle?: string | null;
onCreateProject: () => Promise<void> | void;
onSelectProject: (projectId: string) => Promise<void> | void;
onDeleteProject: (projectId: string) => Promise<void> | void;
onRenameProject: (projectId: string, currentTitle?: string | null) => Promise<void> | void;
}
export function AgentProjectListSheet({
open,
onOpenChange,
currentProjectId,
currentProjectTitle,
onCreateProject,
onSelectProject,
onDeleteProject,
onRenameProject,
}: AgentProjectListSheetProps) {
const [open, setOpen] = useState(false);
const [projects, setProjects] = useState<AgentGenerationProject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [pendingRenameId, setPendingRenameId] = useState<string | null>(null);
const loadProjects = async () => {
setIsLoading(true);
@ -61,17 +64,7 @@ export function AgentProjectListSheet({
}, [open, currentProjectId]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
className="h-9 max-w-[260px] gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
<FolderOpen className="h-4 w-4 shrink-0" />
<span className="truncate">{currentProjectTitle || "生图项目列表"}</span>
</Button>
</SheetTrigger>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[380px] border-white/10 bg-[#0b0d0f] p-0 text-white sm:max-w-[380px]"
@ -89,7 +82,7 @@ export function AgentProjectListSheet({
onClick={async () => {
await onCreateProject();
await loadProjects();
setOpen(false);
onOpenChange(false);
}}
className="h-10 w-full gap-2 bg-emerald-500 text-white hover:bg-emerald-400"
>
@ -111,7 +104,7 @@ export function AgentProjectListSheet({
type="button"
onClick={async () => {
await onSelectProject(project.id);
setOpen(false);
onOpenChange(false);
}}
className={cn(
"group flex w-full items-center gap-3 rounded-2xl border p-3 text-left transition-colors",
@ -155,12 +148,36 @@ export function AgentProjectListSheet({
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-white/45 opacity-0 transition-opacity hover:text-sky-300 group-hover:opacity-100"
disabled={pendingRenameId === project.id}
onClick={async (event) => {
event.stopPropagation();
setPendingRenameId(project.id);
try {
await onRenameProject(project.id, project.title || "");
await loadProjects();
} finally {
setPendingRenameId(null);
}
}}
>
{pendingRenameId === project.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pencil className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-white/45 opacity-0 transition-opacity hover:text-rose-300 group-hover:opacity-100"
disabled={pendingDeleteId === project.id}
disabled={pendingDeleteId === project.id || pendingRenameId === project.id}
onClick={async (event) => {
event.stopPropagation();
if (!window.confirm("确定删除这个生图项目吗?")) return;

View File

@ -8,7 +8,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowUiConfig,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowStoreOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
@ -84,9 +88,13 @@ export function AgentWorkflowPanel({
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const brandStoreEntries = useMemo(
() => getAgentWorkflowBrandStoreEntries(config),
[config]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
[config, inputs.brand]
);
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
@ -197,14 +205,24 @@ export function AgentWorkflowPanel({
placeholder="品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选,请先到系统设置配置"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
onValueChange={(value) =>
onInputsChange({
...inputs,
brand: value,
store:
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
? inputs.store
: "",
})
}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选,请先到系统设置配置"
emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"}
disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)}
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>

View File

@ -9,7 +9,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowUiConfig,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowStoreOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
@ -166,10 +170,13 @@ export function AgentWorkflowPanelDark({
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const brandStoreEntries = useMemo(
() => getAgentWorkflowBrandStoreEntries(config),
[config]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
[config, inputs.brand]
);
const resolvedConfig = useMemo(() => {
@ -305,14 +312,24 @@ export function AgentWorkflowPanelDark({
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
onValueChange={(value) =>
onInputsChange({
...inputs,
brand: value,
store:
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
? inputs.store
: "",
})
}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"}
disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)}
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</motion.div>

View File

@ -12,7 +12,12 @@ import {
defaultAgentWorkflowUiConfig,
flattenAgentWorkflowSteps,
flattenVisibleAgentWorkflowSteps,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowSelectedOptionPath,
getAgentWorkflowStoreOptionsByBrand,
getEnabledAgentWorkflowOptionTree,
getEnabledAgentWorkflowStepTree,
serializeAgentWorkflowSelectionPath,
} from "@/lib/agents/workflow-ui-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
@ -59,16 +64,18 @@ function buildSelectionSummary(params: {
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const { step } of flattenVisibleAgentWorkflowSteps(params.config.steps, params.selections)) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
if (!option) continue;
result.push({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || "",
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
selectedOptions.forEach((option) => {
result.push({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || "",
});
});
}
return result;
@ -181,10 +188,13 @@ export function AgentWorkflowPanelDarkV2({
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const brandStoreEntries = useMemo(
() => getAgentWorkflowBrandStoreEntries(config),
[config]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
[config, inputs.brand]
);
const resolvedConfig = useMemo(() => {
@ -222,6 +232,68 @@ export function AgentWorkflowPanelDarkV2({
[resolvedConfig, selections]
);
const renderOptionLevels = (
step: AgentWorkflowUiConfig["steps"][number],
options: AgentWorkflowUiConfig["steps"][number]["options"],
depth = 1,
parentPath: string[] = []
): ReactNode => {
const selectedPathKeys = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: selections[step.key],
}).map((option) => option.key);
const currentSelectedKey = selectedPathKeys[depth - 1] || "";
const currentSelectedOption =
options.find((option) => option.key === currentSelectedKey) || null;
const visibleChildren = getEnabledAgentWorkflowOptionTree(
currentSelectedOption?.children || []
);
const levelLabel = depth === 1 ? "主卡片" : depth === 2 ? "二级卡片" : "三级卡片";
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/45">
{levelLabel}
</span>
{depth > 1 ? (
<span className="text-xs text-white/35">
该层卡片从上一级已选卡片中继续展开
</span>
) : null}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{options.map((option, optionIndex) => (
<StepOptionCard
key={`${option.key}-${depth}`}
option={option}
step={step}
selected={currentSelectedKey === option.key}
onClick={() => {
const prefix = parentPath.slice(0, depth - 1);
const nextKeys =
currentSelectedKey === option.key ? prefix : [...prefix, option.key];
onSelectionsChange({
...selections,
[step.key]: nextKeys.length > 0 ? serializeAgentWorkflowSelectionPath(nextKeys) : undefined,
});
}}
index={optionIndex}
/>
))}
</div>
{currentSelectedOption && visibleChildren.length > 0
? renderOptionLevels(step, visibleChildren, depth + 1, [
...parentPath.slice(0, depth - 1),
currentSelectedOption.key,
])
: null}
</div>
);
};
const handleCopyPrompt = async () => {
if (!composerContext?.promptText) {
toast.error("当前还没有可复制的 Prompt");
@ -291,15 +363,29 @@ export function AgentWorkflowPanelDarkV2({
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
theme="dark"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
onValueChange={(value) =>
onInputsChange({
...inputs,
brand: value,
store:
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
? inputs.store
: "",
})
}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
emptyText={
inputs.brand
? "当前品牌下暂无店铺可选"
: "请先选择品牌"
}
theme="dark"
disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)}
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>
@ -331,23 +417,7 @@ export function AgentWorkflowPanelDarkV2({
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{step.options.map((option, optionIndex) => (
<StepOptionCard
key={option.key}
option={option}
step={step}
selected={selections[step.key] === option.key}
onClick={() =>
onSelectionsChange({
...selections,
[step.key]: selections[step.key] === option.key ? undefined : option.key,
})
}
index={optionIndex}
/>
))}
</div>
{renderOptionLevels(step, getEnabledAgentWorkflowOptionTree(step.options || []))}
</motion.section>
))}

View File

@ -9,7 +9,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowUiConfig,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowStoreOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
@ -178,10 +182,13 @@ export function AgentWorkflowPanelNew({
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const brandStoreEntries = useMemo(
() => getAgentWorkflowBrandStoreEntries(config),
[config]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
[config, inputs.brand]
);
const resolvedConfig = useMemo(() => {
@ -340,14 +347,24 @@ export function AgentWorkflowPanelNew({
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
onValueChange={(value) =>
onInputsChange({
...inputs,
brand: value,
store:
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
? inputs.store
: "",
})
}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"}
disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)}
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>

View File

@ -1411,7 +1411,7 @@ export function SceneFusionView({
{selectedModeOption?.label || "请选择处理模式"}
</span>
</SelectTrigger>
<SelectContent className="z-[30000] bg-background">
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
{modeOptions.map((option) => (
<SelectItem
className="items-start py-2"

View File

@ -16,6 +16,7 @@ interface SearchableSelectProps {
emptyText?: string;
onValueChange: (value: string) => void;
theme?: "light" | "dark";
disabled?: boolean;
}
export function SearchableSelect({
@ -26,6 +27,7 @@ export function SearchableSelect({
emptyText = "暂无可选项",
onValueChange,
theme = "light",
disabled = false,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@ -51,9 +53,11 @@ export function SearchableSelect({
variant="outline"
className={cn(
"h-10 w-full justify-between px-3 text-left font-normal",
disabled && "cursor-not-allowed opacity-60",
isDark &&
"h-12 rounded-2xl border-white/14 bg-white/[0.05] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-white/[0.08] hover:text-white"
)}
disabled={disabled}
>
<span
className={cn(
@ -87,6 +91,7 @@ export function SearchableSelect({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
disabled={disabled}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/28 focus-visible:ring-emerald-400/30 focus-visible:ring-offset-0"

View File

@ -75,24 +75,28 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-[10000] isolate max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md ring-1 ring-border/50 !opacity-100 backdrop-blur-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin] !bg-white dark:!bg-zinc-950",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
style={style}
style={{
backgroundColor: "var(--popover)",
color: "var(--popover-foreground)",
...style,
}}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"rounded-[inherit] bg-inherit p-1",
"rounded-[inherit] bg-inherit p-1 !bg-white dark:!bg-zinc-950",
position === "popper" &&
"w-full min-w-[var(--radix-select-trigger-width)]"
)}
style={{
backgroundColor: "inherit",
backgroundColor: "var(--popover)",
}}
>
{children}

View File

@ -0,0 +1,335 @@
import type {
AgentCategoryPath,
AgentPresetLibraryCategoryNode,
AgentPresetLibraryConfig,
AgentPresetLibrarySection,
} from "@/types/agent";
export const AGENT_PRESET_LIBRARY_CONFIG_KEY = "agent_preset_library_config";
export const AGENT_PRESET_LIBRARY_MAX_DEPTH = 3;
export const AGENT_PRESET_LIBRARY_MY_WORKS_ID = "my-works";
export const AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID = "cases";
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeBoolean(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeNumber(value: unknown, fallback: number, max = 999) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.min(max, Math.round(parsed)));
}
function normalizeId(value: unknown, fallback: string) {
const normalized = String(value || "")
.trim()
.slice(0, 64)
.replace(/\s+/g, "_")
.replace(/[^\w-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function createDefaultSection(
id: string,
label: string,
sortOrder: number
): AgentPresetLibrarySection {
return {
id,
label,
description: "",
enabled: true,
sortOrder,
categories: [],
};
}
export const defaultAgentPresetLibraryConfig: AgentPresetLibraryConfig = {
sections: [
createDefaultSection("cases", "案例广场", 0),
createDefaultSection("personal-assets", "个人资产库", 1),
createDefaultSection("company-assets", "公司资产库", 2),
],
};
function normalizeNodeArray(
input: unknown,
depth: number,
usedIds: Set<string>
): AgentPresetLibraryCategoryNode[] {
if (!Array.isArray(input) || depth > AGENT_PRESET_LIBRARY_MAX_DEPTH) {
return [];
}
return input
.map((item, index) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const name = normalizeText(raw.name, 64) || `分类${index + 1}`;
const baseId = normalizeId(raw.id, `category_${depth}_${index + 1}`);
let id = baseId;
let duplicateIndex = 2;
while (usedIds.has(id)) {
id = `${baseId}_${duplicateIndex}`;
duplicateIndex += 1;
}
usedIds.add(id);
return {
id,
name,
enabled: normalizeBoolean(raw.enabled, true),
sortOrder: normalizeNumber(raw.sortOrder, index),
children:
depth < AGENT_PRESET_LIBRARY_MAX_DEPTH
? normalizeNodeArray(raw.children, depth + 1, usedIds)
: [],
};
})
.sort((left, right) => left.sortOrder - right.sortOrder);
}
export function normalizeAgentPresetLibraryConfig(
value: unknown
): AgentPresetLibraryConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const hasRawSections = Array.isArray(raw.sections);
const rawSections: unknown[] = hasRawSections
? (raw.sections as unknown[])
: defaultAgentPresetLibraryConfig.sections;
const usedSectionIds = new Set<string>();
const sections = rawSections
.map((item: unknown, index: number) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const fallbackSection =
defaultAgentPresetLibraryConfig.sections[index] ||
createDefaultSection(`section_${index + 1}`, `分类页${index + 1}`, index);
const baseId = normalizeId(record.id, fallbackSection.id);
let id = baseId;
let duplicateIndex = 2;
while (usedSectionIds.has(id)) {
id = `${baseId}_${duplicateIndex}`;
duplicateIndex += 1;
}
usedSectionIds.add(id);
const usedNodeIds = new Set<string>();
return {
id,
label: normalizeText(record.label, 64) || fallbackSection.label,
description: normalizeText(record.description, 200) || "",
enabled: normalizeBoolean(record.enabled, true),
sortOrder: normalizeNumber(record.sortOrder, fallbackSection.sortOrder ?? index),
categories: normalizeNodeArray(record.categories, 1, usedNodeIds),
};
})
.sort(
(left: AgentPresetLibrarySection, right: AgentPresetLibrarySection) =>
left.sortOrder - right.sortOrder
);
return {
sections: hasRawSections
? sections
: sections.length > 0
? sections
: defaultAgentPresetLibraryConfig.sections,
};
}
export function getEnabledAgentPresetLibrarySections(
config: AgentPresetLibraryConfig
) {
return [...config.sections]
.filter((section) => section.enabled !== false)
.sort((left, right) => left.sortOrder - right.sortOrder);
}
export function getEnabledAgentPresetLibraryNodes(
nodes: AgentPresetLibraryCategoryNode[]
): AgentPresetLibraryCategoryNode[] {
return [...nodes]
.filter((node) => node.enabled !== false)
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((node) => ({
...node,
children: getEnabledAgentPresetLibraryNodes(node.children || []),
}));
}
function findNodeByMatcher(
nodes: AgentPresetLibraryCategoryNode[],
matcher: (node: AgentPresetLibraryCategoryNode) => boolean
): AgentPresetLibraryCategoryNode | null {
for (const node of nodes) {
if (matcher(node)) {
return node;
}
const foundChild = findNodeByMatcher(node.children || [], matcher);
if (foundChild) {
return foundChild;
}
}
return null;
}
export function normalizeAgentCategoryPath(value: unknown): AgentCategoryPath {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
sectionId: normalizeText(raw.sectionId, 64) || null,
sectionName: normalizeText(raw.sectionName, 64) || null,
level1Id: normalizeText(raw.level1Id, 64) || null,
level1Name: normalizeText(raw.level1Name, 64) || normalizeText(raw.productCategory, 64) || null,
level2Id: normalizeText(raw.level2Id, 64) || null,
level2Name: normalizeText(raw.level2Name, 64) || normalizeText(raw.audienceTag, 64) || null,
level3Id: normalizeText(raw.level3Id, 64) || null,
level3Name: normalizeText(raw.level3Name, 64) || normalizeText(raw.usageScenario, 64) || null,
productCategory: normalizeText(raw.productCategory, 64) || null,
audienceTag: normalizeText(raw.audienceTag, 64) || null,
usageScenario: normalizeText(raw.usageScenario, 64) || null,
brand: normalizeText(raw.brand, 64) || null,
};
}
export interface ResolvedAgentPresetCategoryPath {
section: AgentPresetLibrarySection | null;
level1: AgentPresetLibraryCategoryNode | null;
level2: AgentPresetLibraryCategoryNode | null;
level3: AgentPresetLibraryCategoryNode | null;
sectionId: string | null;
sectionName: string | null;
level1Id: string | null;
level1Name: string | null;
level2Id: string | null;
level2Name: string | null;
level3Id: string | null;
level3Name: string | null;
}
export function resolveAgentPresetCategoryPath(params: {
categoryPath?: AgentCategoryPath | null;
config?: AgentPresetLibraryConfig | null;
}) : ResolvedAgentPresetCategoryPath {
const normalizedPath = normalizeAgentCategoryPath(params.categoryPath);
const config = normalizeAgentPresetLibraryConfig(
params.config || defaultAgentPresetLibraryConfig
);
const sections = getEnabledAgentPresetLibrarySections(config);
const fallbackSection =
sections.find((section) => section.id === AGENT_PRESET_LIBRARY_DEFAULT_SECTION_ID) ||
sections[0] ||
null;
const section =
sections.find((item) => item.id === normalizedPath.sectionId) ||
sections.find((item) => item.label === normalizedPath.sectionName) ||
((!normalizedPath.sectionId &&
!normalizedPath.sectionName &&
(normalizedPath.level1Name || normalizedPath.level2Name || normalizedPath.level3Name)) ||
normalizedPath.productCategory ||
normalizedPath.audienceTag ||
normalizedPath.usageScenario
? fallbackSection
: null);
const level1 =
section
? findNodeByMatcher(getEnabledAgentPresetLibraryNodes(section.categories), (node) => {
return (
node.id === normalizedPath.level1Id ||
node.name === normalizedPath.level1Name ||
node.name === normalizedPath.productCategory
);
})
: null;
const level2 =
level1
? findNodeByMatcher(getEnabledAgentPresetLibraryNodes(level1.children || []), (node) => {
return (
node.id === normalizedPath.level2Id ||
node.name === normalizedPath.level2Name ||
node.name === normalizedPath.audienceTag
);
})
: null;
const level3 =
level2
? findNodeByMatcher(getEnabledAgentPresetLibraryNodes(level2.children || []), (node) => {
return (
node.id === normalizedPath.level3Id ||
node.name === normalizedPath.level3Name ||
node.name === normalizedPath.usageScenario
);
})
: null;
return {
section,
level1,
level2,
level3,
sectionId: section?.id || normalizedPath.sectionId || null,
sectionName: section?.label || normalizedPath.sectionName || null,
level1Id: level1?.id || normalizedPath.level1Id || null,
level1Name:
level1?.name || normalizedPath.level1Name || normalizedPath.productCategory || null,
level2Id: level2?.id || normalizedPath.level2Id || null,
level2Name:
level2?.name || normalizedPath.level2Name || normalizedPath.audienceTag || null,
level3Id: level3?.id || normalizedPath.level3Id || null,
level3Name:
level3?.name || normalizedPath.level3Name || normalizedPath.usageScenario || null,
};
}
export function buildAgentPresetCategoryPath(params: {
section?: AgentPresetLibrarySection | null;
level1?: AgentPresetLibraryCategoryNode | null;
level2?: AgentPresetLibraryCategoryNode | null;
level3?: AgentPresetLibraryCategoryNode | null;
}): AgentCategoryPath | null {
const section = params.section || null;
const level1 = params.level1 || null;
const level2 = params.level2 || null;
const level3 = params.level3 || null;
if (!section && !level1 && !level2 && !level3) {
return null;
}
return {
sectionId: section?.id || null,
sectionName: section?.label || null,
level1Id: level1?.id || null,
level1Name: level1?.name || null,
level2Id: level2?.id || null,
level2Name: level2?.name || null,
level3Id: level3?.id || null,
level3Name: level3?.name || null,
productCategory: level1?.name || null,
audienceTag: level2?.name || null,
usageScenario: level3?.name || null,
brand: null,
};
}

View File

@ -1,7 +1,9 @@
import {
defaultAgentWorkflowUiConfig,
flattenVisibleAgentWorkflowSteps,
getAgentWorkflowSelectedOptionPath,
getEnabledAgentWorkflowStepTree,
isAgentWorkflowStepSelectionComplete,
} from "@/lib/agents/workflow-ui-config";
import type {
AgentWorkflowComposerContext,
@ -35,21 +37,20 @@ export function buildAgentWorkflowComposerContext(params: {
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps
.map((step) => {
const selectedKey = params.selections[step.key];
if (!selectedKey) return null;
const option = (step.options || []).find(
(item) => item.key === selectedKey && item.enabled !== false
);
if (!option) return null;
return {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
if (selectedOptions.length === 0) return [];
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
};
}));
})
.filter(Boolean) as AgentWorkflowComposerContext["selections"];
.flat() as AgentWorkflowComposerContext["selections"];
const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs,
materials: params.materials,
@ -231,8 +232,10 @@ export function getAgentWorkflowProgress(params: {
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const completedSteps = enabledSteps.filter((step) => {
const value = params.selections[step.key];
return Boolean(String(value || "").trim());
return isAgentWorkflowStepSelectionComplete({
step,
selectionValue: params.selections[step.key],
});
});
return {

View File

@ -1,4 +1,5 @@
import type {
AgentWorkflowBrandStoreEntry,
AgentWorkflowOptionUiConfig,
AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig,
@ -6,6 +7,7 @@ import type {
export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 3;
export const AGENT_WORKFLOW_MAX_OPTION_DEPTH = 3;
export interface FlattenedAgentWorkflowStep {
step: AgentWorkflowStepUiConfig;
@ -21,6 +23,7 @@ function cloneOption(
description: option.description || "",
previewImageUrl: option.previewImageUrl || "",
promptFragment: option.promptFragment || "",
children: (option.children || []).map(cloneOption),
};
}
@ -64,6 +67,7 @@ function createOption(
enabled: true,
sortOrder,
promptFragment,
children: [],
};
}
@ -129,6 +133,7 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
inputOptions: {
brands: [],
stores: [],
brandStoreEntries: [],
},
steps: defaultSteps.map((step, index) =>
cloneStep({
@ -216,7 +221,9 @@ export function countAgentWorkflowSteps(steps: AgentWorkflowStepUiConfig[]): num
export function countAgentWorkflowOptions(steps: AgentWorkflowStepUiConfig[]): number {
return flattenAgentWorkflowSteps(steps).reduce(
(total, item) => total + (item.step.options?.length || 0),
(total, item) =>
total +
flattenAgentWorkflowOptionTree(item.step.options || []).length,
0
);
}
@ -227,10 +234,7 @@ export function normalizeAgentWorkflowStepOrdering(
return steps.map((step, stepIndex) => ({
...step,
sortOrder: stepIndex,
options: (step.options || []).map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
})),
options: normalizeAgentWorkflowOptionOrdering(step.options || []),
children: normalizeAgentWorkflowStepOrdering(step.children || []),
}));
}
@ -243,14 +247,81 @@ export function getEnabledAgentWorkflowStepTree(
.filter((step) => step.enabled !== false)
.map((step) => ({
...step,
options: [...(step.options || [])]
.filter((option) => option.enabled !== false)
.sort((left, right) => left.sortOrder - right.sortOrder),
options: getEnabledAgentWorkflowOptionTree(step.options || []),
children: getEnabledAgentWorkflowStepTree(step.children || []),
}))
.filter((step) => step.options.length > 0 || (step.children || []).length > 0);
}
export interface FlattenedAgentWorkflowOption {
option: AgentWorkflowOptionUiConfig;
depth: number;
parentKeys: string[];
}
export function flattenAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[],
depth = 1,
parentKeys: string[] = []
): FlattenedAgentWorkflowOption[] {
return options.flatMap((option) => [
{
option,
depth,
parentKeys,
},
...flattenAgentWorkflowOptionTree(
option.children || [],
depth + 1,
[...parentKeys, option.key]
),
]);
}
export function countAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[]
): number {
return flattenAgentWorkflowOptionTree(options).length;
}
export function normalizeAgentWorkflowOptionOrdering(
options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowOptionUiConfig[] {
return options.map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
children: normalizeAgentWorkflowOptionOrdering(option.children || []),
}));
}
export function getEnabledAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowOptionUiConfig[] {
return [...options]
.sort((left, right) => left.sortOrder - right.sortOrder)
.filter((option) => option.enabled !== false)
.map((option) => ({
...option,
children: getEnabledAgentWorkflowOptionTree(option.children || []),
}));
}
export function findAgentWorkflowOptionByKey(
options: AgentWorkflowOptionUiConfig[],
optionKey: string
): AgentWorkflowOptionUiConfig | null {
for (const option of options) {
if (option.key === optionKey) {
return option;
}
const foundChild = findAgentWorkflowOptionByKey(option.children || [], optionKey);
if (foundChild) {
return foundChild;
}
}
return null;
}
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
@ -299,6 +370,46 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
raw.inputOptions && typeof raw.inputOptions === "object" && !Array.isArray(raw.inputOptions)
? (raw.inputOptions as Record<string, unknown>)
: {};
const normalizeBrandStoreEntries = (input: unknown) => {
if (!Array.isArray(input)) return [] as AgentWorkflowBrandStoreEntry[];
const mappedEntries: Array<AgentWorkflowBrandStoreEntry | null> = input.map((item, index) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const brandName = normalizeText(record.brandName, 64);
if (!brandName) return null;
const stores = normalizeStringList(record.stores).slice(0, 300);
return {
brandName,
stores,
sortOrder: normalizeNumber(record.sortOrder, index),
};
});
return mappedEntries
.filter((item): item is AgentWorkflowBrandStoreEntry => item !== null)
.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0))
.map((entry, index) => ({
...entry,
sortOrder: index,
}));
};
const normalizedBrandStoreEntries = normalizeBrandStoreEntries(
rawInputOptions.brandStoreEntries
);
const normalizedBrands = normalizeStringList(rawInputOptions.brands);
const normalizedStores = normalizeStringList(rawInputOptions.stores);
const fallbackBrandStoreEntries: AgentWorkflowBrandStoreEntry[] =
normalizedBrandStoreEntries.length > 0
? normalizedBrandStoreEntries
: normalizedBrands.map((brandName, index) => ({
brandName,
stores: normalizedBrands.length === 1 ? normalizedStores : [],
sortOrder: index,
}));
const defaultStepByKey = new Map(
flattenAgentWorkflowSteps(defaultAgentWorkflowUiConfig.steps).map(({ step }) => [
step.key,
@ -308,6 +419,82 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
const sourceSteps = hasRawSteps ? rawSteps : (defaultAgentWorkflowUiConfig.steps as unknown[]);
const usedStepKeys = new Set<string>();
const normalizeOption = (
item: unknown,
optionIndex: number,
stepKey: string,
usedOptionKeys: Set<string>,
defaultOptions: AgentWorkflowOptionUiConfig[],
depth: number
): AgentWorkflowOptionUiConfig => {
const optionRecord =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const defaultOption = defaultOptions.find(
(defaultItem) => defaultItem.key === normalizeText(optionRecord.key, 64)
);
const optionBaseKey = normalizeKey(
optionRecord.key,
defaultOption?.key || `${stepKey}_option_${optionIndex + 1}`
);
let optionKey = optionBaseKey;
let optionDuplicateIndex = 2;
while (usedOptionKeys.has(optionKey)) {
optionKey = `${optionBaseKey}_${optionDuplicateIndex}`;
optionDuplicateIndex += 1;
}
usedOptionKeys.add(optionKey);
const buttonName =
normalizeText(optionRecord.buttonName, 64) ||
defaultOption?.buttonName ||
`选项${optionIndex + 1}`;
const promptFragment =
normalizeText(optionRecord.promptFragment, 1000) ||
defaultOption?.promptFragment ||
buttonName;
const hasRawChildren = Array.isArray(optionRecord.children);
const rawChildren = hasRawChildren ? (optionRecord.children as unknown[]) : [];
const defaultChildren =
!hasRawChildren && defaultOption?.children ? defaultOption.children : [];
const childSource =
depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH && (rawChildren.length > 0 || hasRawChildren)
? rawChildren
: depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH
? defaultChildren
: [];
return {
key: optionKey,
buttonName,
description:
normalizeText(optionRecord.description, 300) || defaultOption?.description || "",
previewImageUrl: normalizeText(optionRecord.previewImageUrl, 1000),
enabled: normalizeBoolean(optionRecord.enabled, defaultOption?.enabled ?? true),
sortOrder: normalizeNumber(
optionRecord.sortOrder,
defaultOption?.sortOrder ?? optionIndex
),
promptFragment,
children:
depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH
? childSource
.map((child, childIndex) =>
normalizeOption(
child,
childIndex,
optionKey,
usedOptionKeys,
defaultOption?.children || [],
depth + 1
)
)
.sort((left, right) => left.sortOrder - right.sortOrder)
: [],
};
};
const normalizeStep = (
item: unknown,
stepIndex: number,
@ -336,49 +523,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
const usedOptionKeys = new Set<string>();
const options = optionSource
.map((option, optionIndex) => {
const optionRecord =
option && typeof option === "object" && !Array.isArray(option)
? (option as Record<string, unknown>)
: null;
const defaultOption = defaultStep?.options.find(
(defaultItem) => defaultItem.key === normalizeText(optionRecord?.key, 64)
);
const optionBaseKey = normalizeKey(
optionRecord?.key,
defaultOption?.key || `${stepKey}_option_${optionIndex + 1}`
);
let optionKey = optionBaseKey;
let optionDuplicateIndex = 2;
while (usedOptionKeys.has(optionKey)) {
optionKey = `${optionBaseKey}_${optionDuplicateIndex}`;
optionDuplicateIndex += 1;
}
usedOptionKeys.add(optionKey);
const buttonName =
normalizeText(optionRecord?.buttonName, 64) ||
defaultOption?.buttonName ||
`选项${optionIndex + 1}`;
const promptFragment =
normalizeText(optionRecord?.promptFragment, 1000) ||
defaultOption?.promptFragment ||
buttonName;
return {
key: optionKey,
buttonName,
description:
normalizeText(optionRecord?.description, 300) || defaultOption?.description || "",
previewImageUrl: normalizeText(optionRecord?.previewImageUrl, 1000),
enabled: normalizeBoolean(optionRecord?.enabled, defaultOption?.enabled ?? true),
sortOrder: normalizeNumber(
optionRecord?.sortOrder,
defaultOption?.sortOrder ?? optionIndex
),
promptFragment,
};
})
.map((option, optionIndex) =>
normalizeOption(option, optionIndex, stepKey, usedOptionKeys, defaultOptions, 1)
)
.sort((left, right) => left.sortOrder - right.sortOrder);
const hasRawChildren = Array.isArray(stepRecord?.children);
@ -414,9 +561,82 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
return {
inputOptions: {
brands: normalizeStringList(rawInputOptions.brands),
stores: normalizeStringList(rawInputOptions.stores),
brands:
fallbackBrandStoreEntries.length > 0
? fallbackBrandStoreEntries.map((entry) => entry.brandName)
: normalizedBrands,
stores:
fallbackBrandStoreEntries.length > 0
? Array.from(
new Set(fallbackBrandStoreEntries.flatMap((entry) => entry.stores))
)
: normalizedStores,
brandStoreEntries: fallbackBrandStoreEntries,
},
steps: normalizedSteps,
};
}
export function getAgentWorkflowBrandStoreEntries(config?: AgentWorkflowUiConfig | null) {
return config?.inputOptions?.brandStoreEntries || defaultAgentWorkflowUiConfig.inputOptions?.brandStoreEntries || [];
}
export function getAgentWorkflowStoreOptionsByBrand(params: {
config?: AgentWorkflowUiConfig | null;
brand?: string | null;
}) {
const entries = getAgentWorkflowBrandStoreEntries(params.config);
const normalizedBrand = String(params.brand || "").trim().toLowerCase();
if (!normalizedBrand) {
return [];
}
const matchedEntry = entries.find(
(entry) => entry.brandName.trim().toLowerCase() === normalizedBrand
);
if (matchedEntry && matchedEntry.stores.length > 0) {
return matchedEntry.stores;
}
return params.config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [];
}
export function parseAgentWorkflowSelectionPath(value?: string | null) {
return String(value || "")
.split(">")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, AGENT_WORKFLOW_MAX_OPTION_DEPTH);
}
export function serializeAgentWorkflowSelectionPath(optionKeys: string[]) {
return optionKeys.filter(Boolean).slice(0, AGENT_WORKFLOW_MAX_OPTION_DEPTH).join(">");
}
export function getAgentWorkflowSelectedOptionPath(params: {
step: AgentWorkflowStepUiConfig;
selectionValue?: string | null;
}) {
const keys = parseAgentWorkflowSelectionPath(params.selectionValue);
const selectedOptions: AgentWorkflowOptionUiConfig[] = [];
let currentOptions = params.step.options || [];
for (const key of keys) {
const matchedOption = currentOptions.find((option) => option.key === key);
if (!matchedOption) break;
selectedOptions.push(matchedOption);
currentOptions = matchedOption.children || [];
}
return selectedOptions;
}
export function isAgentWorkflowStepSelectionComplete(params: {
step: AgentWorkflowStepUiConfig;
selectionValue?: string | null;
}) {
const selectedPath = getAgentWorkflowSelectedOptionPath(params);
if (selectedPath.length === 0) return false;
const lastOption = selectedPath[selectedPath.length - 1];
return getEnabledAgentWorkflowOptionTree(lastOption.children || []).length === 0;
}

View File

@ -2493,6 +2493,14 @@ export async function createAgentPreset(params: {
secondaryType?: string | null;
visibility: AgentPresetVisibility;
categoryPath?: {
sectionId?: string | null;
sectionName?: string | null;
level1Id?: string | null;
level1Name?: string | null;
level2Id?: string | null;
level2Name?: string | null;
level3Id?: string | null;
level3Name?: string | null;
productCategory?: string | null;
audienceTag?: string | null;
usageScenario?: string | null;
@ -2613,6 +2621,14 @@ export async function updateAgentPreset(params: {
secondaryType?: string | null;
visibility?: AgentPresetVisibility;
categoryPath?: {
sectionId?: string | null;
sectionName?: string | null;
level1Id?: string | null;
level1Name?: string | null;
level2Id?: string | null;
level2Name?: string | null;
level3Id?: string | null;
level3Name?: string | null;
productCategory?: string | null;
audienceTag?: string | null;
usageScenario?: string | null;

View File

@ -532,6 +532,14 @@ export const agentPreset = pgTable("AgentPreset", {
onDelete: "set null",
}),
categoryPath: jsonb("categoryPath").$type<{
sectionId?: string | null;
sectionName?: string | null;
level1Id?: string | null;
level1Name?: string | null;
level2Id?: string | null;
level2Name?: string | null;
level3Id?: string | null;
level3Name?: string | null;
productCategory?: string | null;
audienceTag?: string | null;
usageScenario?: string | null;

View File

@ -35,12 +35,41 @@ export interface AgentPresetModeConfig {
}
export interface AgentCategoryPath {
sectionId?: string | null;
sectionName?: string | null;
level1Id?: string | null;
level1Name?: string | null;
level2Id?: string | null;
level2Name?: string | null;
level3Id?: string | null;
level3Name?: string | null;
productCategory?: string | null;
audienceTag?: string | null;
usageScenario?: string | null;
brand?: string | null;
}
export interface AgentPresetLibraryCategoryNode {
id: string;
name: string;
enabled: boolean;
sortOrder: number;
children: AgentPresetLibraryCategoryNode[];
}
export interface AgentPresetLibrarySection {
id: string;
label: string;
description?: string | null;
enabled: boolean;
sortOrder: number;
categories: AgentPresetLibraryCategoryNode[];
}
export interface AgentPresetLibraryConfig {
sections: AgentPresetLibrarySection[];
}
export type AgentWorkflowStepKey = string;
export interface AgentWorkflowOptionUiConfig {
@ -51,6 +80,7 @@ export interface AgentWorkflowOptionUiConfig {
enabled: boolean;
sortOrder: number;
promptFragment?: string | null;
children?: AgentWorkflowOptionUiConfig[];
}
export interface AgentWorkflowStepUiConfig {
@ -67,10 +97,17 @@ export interface AgentWorkflowUiConfig {
inputOptions?: {
brands: string[];
stores: string[];
brandStoreEntries?: AgentWorkflowBrandStoreEntry[];
};
steps: AgentWorkflowStepUiConfig[];
}
export interface AgentWorkflowBrandStoreEntry {
brandName: string;
stores: string[];
sortOrder?: number;
}
export interface AgentWorkflowMaterialApiConfig {
enabled: boolean;
baseUrl: string;