feat: add brand model selection to agent flow
This commit is contained in:
parent
afc9e187b6
commit
dae5f59417
@ -16,6 +16,10 @@ import {
|
||||
defaultAgentWorkflowUiConfig,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
defaultAgentWorkflowBrandModelConfig,
|
||||
normalizeAgentWorkflowBrandModelConfig,
|
||||
} from "@/lib/agents/workflow-brand-model-config";
|
||||
import {
|
||||
defaultAgentWorkflowMaterialApiConfig,
|
||||
normalizeAgentWorkflowMaterialApiConfig,
|
||||
@ -27,7 +31,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { AgentWorkflowMaterialApiConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
||||
import type {
|
||||
AgentWorkflowBrandModelConfig,
|
||||
AgentWorkflowMaterialApiConfig,
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
|
||||
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
|
||||
|
||||
@ -35,11 +43,15 @@ export function AgentWorkflowSettingsClient() {
|
||||
const { data, isLoading, mutate } = useSWR<{
|
||||
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
|
||||
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
|
||||
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
|
||||
}>("/api/site-settings", fetcher);
|
||||
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
|
||||
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
|
||||
defaultAgentWorkflowMaterialApiConfig
|
||||
);
|
||||
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
|
||||
defaultAgentWorkflowBrandModelConfig
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
@ -50,8 +62,24 @@ export function AgentWorkflowSettingsClient() {
|
||||
setMaterialApiConfig(
|
||||
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
|
||||
);
|
||||
setBrandModelConfig(
|
||||
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const configuredBrands = useMemo(
|
||||
() => config.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
|
||||
[config.inputOptions?.brands]
|
||||
);
|
||||
const brandModelBrands = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...configuredBrands,
|
||||
...brandModelConfig.entries.map((entry) => entry.brandName),
|
||||
])
|
||||
).sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
}, [brandModelConfig.entries, configuredBrands]);
|
||||
|
||||
const sortedSteps = useMemo(
|
||||
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
[config.steps]
|
||||
@ -122,6 +150,7 @@ export function AgentWorkflowSettingsClient() {
|
||||
body: JSON.stringify({
|
||||
agentWorkflowUiConfig: config,
|
||||
agentWorkflowMaterialApiConfig: materialApiConfig,
|
||||
agentWorkflowBrandModelConfig: brandModelConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -138,6 +167,106 @@ export function AgentWorkflowSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateBrandModelEntry = (
|
||||
brandName: string,
|
||||
updater: (
|
||||
entry: NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
|
||||
) => NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
|
||||
) => {
|
||||
setBrandModelConfig((prev) => {
|
||||
const entryIndex = prev.entries.findIndex((entry) => entry.brandName === brandName);
|
||||
const currentEntry =
|
||||
entryIndex >= 0
|
||||
? prev.entries[entryIndex]
|
||||
: {
|
||||
brandName,
|
||||
images: [],
|
||||
};
|
||||
const nextEntry = updater(currentEntry);
|
||||
const nextEntries =
|
||||
entryIndex >= 0
|
||||
? prev.entries.map((entry, index) => (index === entryIndex ? nextEntry : entry))
|
||||
: [...prev.entries, nextEntry];
|
||||
|
||||
return {
|
||||
entries: nextEntries
|
||||
.filter((entry) => entry.images.length > 0 || configuredBrands.includes(entry.brandName))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
images: entry.images
|
||||
.slice()
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((image, index) => ({
|
||||
...image,
|
||||
sortOrder: index,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadBrandModelImages = async (brandName: string, files: FileList | null) => {
|
||||
const pickedFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
if (pickedFiles.length === 0) {
|
||||
toast.error("请先选择图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
const refKey = `brand-model:${brandName}`;
|
||||
try {
|
||||
setUploadingKey(refKey);
|
||||
const uploadedImages: AgentWorkflowBrandModelConfig["entries"][number]["images"] = [];
|
||||
|
||||
for (const file of pickedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.url) {
|
||||
throw new Error(result?.error || "上传失败");
|
||||
}
|
||||
|
||||
uploadedImages.push({
|
||||
id:
|
||||
(typeof result.assetId === "string" && result.assetId.trim()) ||
|
||||
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
assetId: typeof result.assetId === "string" ? result.assetId : null,
|
||||
imageUrl: result.url,
|
||||
displayName: file.name,
|
||||
mimeType: file.type || null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
updateBrandModelEntry(brandName, (entry) => ({
|
||||
...entry,
|
||||
images: [
|
||||
...entry.images,
|
||||
...uploadedImages.map((image, index) => ({
|
||||
...image,
|
||||
sortOrder: entry.images.length + index,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
toast.success(
|
||||
uploadedImages.length === 1 ? "模特图上传成功" : `已上传 ${uploadedImages.length} 张模特图`
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "模特图上传失败");
|
||||
} finally {
|
||||
setUploadingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBrandModelImage = (brandName: string, imageId: string) => {
|
||||
updateBrandModelEntry(brandName, (entry) => ({
|
||||
...entry,
|
||||
images: entry.images.filter((image) => image.id !== imageId),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -296,6 +425,115 @@ export function AgentWorkflowSettingsClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>品牌模特图库</CardTitle>
|
||||
<CardDescription>
|
||||
第二步会根据第一步选择的品牌,展示这里配置的品牌模特图供用户单选,并自动带入生图流程的模特图槽位。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{brandModelBrands.length > 0 ? (
|
||||
brandModelBrands.map((brandName) => {
|
||||
const refKey = `brand-model:${brandName}`;
|
||||
const entry =
|
||||
brandModelConfig.entries.find((item) => item.brandName === brandName) || null;
|
||||
const images = entry?.images || [];
|
||||
const isUploading = uploadingKey === refKey;
|
||||
|
||||
return (
|
||||
<div key={brandName} className="rounded-2xl border bg-muted/10 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{brandName}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
当前已配置 {images.length} 张模特图
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={(element) => {
|
||||
fileInputRefs.current[refKey] = element;
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleUploadBrandModelImages(brandName, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRefs.current[refKey]?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
上传模特图
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 0 ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="overflow-hidden rounded-2xl border bg-background"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-muted/30">
|
||||
<img
|
||||
src={image.imageUrl}
|
||||
alt={image.displayName || `${brandName} 模特图`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="min-w-0 text-[11px] text-muted-foreground">
|
||||
<div className="truncate">
|
||||
{image.displayName || `${brandName} 模特图`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
handleRemoveBrandModelImage(brandName, image.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
当前品牌还没有上传模特图,上传后第二步会自动按品牌展示给用户选择。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
先在上方“品牌列表”里配置品牌,随后即可为每个品牌上传对应的模特图。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{sortedSteps.map((step) => (
|
||||
<Card key={step.key}>
|
||||
<CardHeader>
|
||||
|
||||
@ -2741,12 +2741,21 @@ function normalizeAgentSupplementPrompt(text: string, presetName?: string | null
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAgentWorkflowPrompt(text: string) {
|
||||
return String(text || "").trim();
|
||||
}
|
||||
|
||||
function buildAgentSlotPrompt(params: {
|
||||
workflowPrompt?: string | null;
|
||||
slotPrompt: string;
|
||||
slotTitle?: string | null;
|
||||
userSupplementPrompt?: string | null;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
|
||||
if (workflowPrompt) {
|
||||
lines.push(workflowPrompt);
|
||||
}
|
||||
const slotTitle = String(params.slotTitle || "").trim();
|
||||
if (slotTitle) {
|
||||
lines.push(`【槽位】${slotTitle}`);
|
||||
@ -5521,6 +5530,7 @@ export async function POST(request: Request) {
|
||||
),
|
||||
);
|
||||
const slotPrompt = buildAgentSlotPrompt({
|
||||
workflowPrompt: hiddenPromptText || null,
|
||||
slotPrompt: slot.promptTemplate,
|
||||
slotTitle: slot.title || slot.uploadLabel || null,
|
||||
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
|
||||
|
||||
@ -34,6 +34,11 @@ import {
|
||||
defaultAgentWorkflowUiConfig,
|
||||
normalizeAgentWorkflowUiConfig,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
|
||||
defaultAgentWorkflowBrandModelConfig,
|
||||
normalizeAgentWorkflowBrandModelConfig,
|
||||
} from "@/lib/agents/workflow-brand-model-config";
|
||||
import {
|
||||
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||
defaultAgentWorkflowMaterialApiConfig,
|
||||
@ -227,6 +232,7 @@ export async function GET() {
|
||||
agentSecondaryTypeConfigSetting,
|
||||
agentWorkflowUiConfigSetting,
|
||||
agentWorkflowMaterialApiConfigSetting,
|
||||
agentWorkflowBrandModelConfigSetting,
|
||||
] = await Promise.all([
|
||||
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
||||
db
|
||||
@ -349,6 +355,12 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
]);
|
||||
|
||||
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||
@ -398,6 +410,9 @@ export async function GET() {
|
||||
: normalizeAgentWorkflowMaterialApiConfigForPublic(
|
||||
agentWorkflowMaterialApiConfigSetting?.value
|
||||
);
|
||||
const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig(
|
||||
agentWorkflowBrandModelConfigSetting?.value
|
||||
);
|
||||
|
||||
// 如果没有设置记录,返回默认值
|
||||
const baseSettings = settings || {
|
||||
@ -427,6 +442,7 @@ export async function GET() {
|
||||
agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
||||
agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig,
|
||||
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasServiceEnabled: true,
|
||||
storageFailoverConfig,
|
||||
@ -472,6 +488,7 @@ export async function GET() {
|
||||
agentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig,
|
||||
agentWorkflowMaterialApiConfig,
|
||||
agentWorkflowBrandModelConfig,
|
||||
membershipConfig,
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
@ -576,6 +593,29 @@ const updateSchema = z.object({
|
||||
apiKey: z.string().trim().max(500).optional(),
|
||||
})
|
||||
.optional(),
|
||||
agentWorkflowBrandModelConfig: z
|
||||
.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
brandName: z.string().trim().min(1).max(64),
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).max(128).optional(),
|
||||
assetId: z.string().trim().max(128).nullable().optional(),
|
||||
imageUrl: z.string().trim().min(1).max(2000),
|
||||
displayName: z.string().trim().max(255).nullable().optional(),
|
||||
mimeType: z.string().trim().max(128).nullable().optional(),
|
||||
sortOrder: z.number().min(0).max(999).optional(),
|
||||
})
|
||||
)
|
||||
.max(100),
|
||||
})
|
||||
)
|
||||
.max(300),
|
||||
})
|
||||
.optional(),
|
||||
realtimeCanvasEnabled: z.boolean().optional(),
|
||||
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||
canvasDefaultModelId: z.string().nullable().optional(),
|
||||
@ -655,6 +695,7 @@ export async function PUT(request: NextRequest) {
|
||||
agentSecondaryTypeConfig,
|
||||
agentWorkflowUiConfig,
|
||||
agentWorkflowMaterialApiConfig,
|
||||
agentWorkflowBrandModelConfig,
|
||||
membershipConfig,
|
||||
membershipUserOverrides,
|
||||
pointsSystemConfig,
|
||||
@ -901,6 +942,28 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (agentWorkflowBrandModelConfig !== undefined) {
|
||||
const normalized = normalizeAgentWorkflowBrandModelConfig(
|
||||
agentWorkflowBrandModelConfig
|
||||
);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: AGENT_WORKFLOW_BRAND_MODEL_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 (membershipConfig !== undefined) {
|
||||
const normalized = normalizeMembershipConfig(membershipConfig);
|
||||
await db
|
||||
@ -1081,6 +1144,10 @@ export async function PUT(request: NextRequest) {
|
||||
agentWorkflowMaterialApiConfig !== undefined
|
||||
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
|
||||
: defaultAgentWorkflowMaterialApiConfig,
|
||||
agentWorkflowBrandModelConfig:
|
||||
agentWorkflowBrandModelConfig !== undefined
|
||||
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
|
||||
: defaultAgentWorkflowBrandModelConfig,
|
||||
membershipConfig:
|
||||
membershipConfig !== undefined
|
||||
? normalizeMembershipConfig(membershipConfig)
|
||||
@ -1371,6 +1438,28 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (agentWorkflowBrandModelConfig !== undefined) {
|
||||
const normalized = normalizeAgentWorkflowBrandModelConfig(
|
||||
agentWorkflowBrandModelConfig
|
||||
);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: AGENT_WORKFLOW_BRAND_MODEL_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 (membershipConfig !== undefined) {
|
||||
const normalized = normalizeMembershipConfig(membershipConfig);
|
||||
await db
|
||||
@ -1551,6 +1640,10 @@ export async function PUT(request: NextRequest) {
|
||||
agentWorkflowMaterialApiConfig !== undefined
|
||||
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
|
||||
: defaultAgentWorkflowMaterialApiConfig,
|
||||
agentWorkflowBrandModelConfig:
|
||||
agentWorkflowBrandModelConfig !== undefined
|
||||
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
|
||||
: defaultAgentWorkflowBrandModelConfig,
|
||||
membershipConfig:
|
||||
membershipConfig !== undefined
|
||||
? normalizeMembershipConfig(membershipConfig)
|
||||
|
||||
@ -63,6 +63,7 @@ import {
|
||||
type AgentWorkflowInputs,
|
||||
} from "@/components/agents/AgentWorkflowPanel";
|
||||
import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace";
|
||||
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
|
||||
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
|
||||
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
@ -89,6 +90,7 @@ import type {
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowMaterialSelections,
|
||||
AgentWorkflowPersistedState,
|
||||
AgentWorkflowSelectedBrandModel,
|
||||
AgentWorkflowStepKey,
|
||||
} from "@/types/agent";
|
||||
|
||||
@ -175,6 +177,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const [workflowSelections, setWorkflowSelections] = useState<
|
||||
Partial<Record<AgentWorkflowStepKey, string>>
|
||||
>({});
|
||||
const [selectedBrandModel, setSelectedBrandModel] =
|
||||
useState<AgentWorkflowSelectedBrandModel | null>(null);
|
||||
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
|
||||
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
|
||||
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
|
||||
@ -280,6 +284,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
});
|
||||
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
|
||||
setWorkflowSelections({});
|
||||
setSelectedBrandModel(null);
|
||||
setSelectedAgentPresetId(null);
|
||||
|
||||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||||
@ -303,6 +308,33 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
|
||||
);
|
||||
setWorkflowSelections(persistedWorkflow.selections || {});
|
||||
setSelectedBrandModel(
|
||||
persistedWorkflow.selectedBrandModel
|
||||
? {
|
||||
...persistedWorkflow.selectedBrandModel,
|
||||
brandName: String(persistedWorkflow.selectedBrandModel.brandName || "").trim(),
|
||||
id: String(persistedWorkflow.selectedBrandModel.id || "").trim(),
|
||||
imageUrl: String(persistedWorkflow.selectedBrandModel.imageUrl || "").trim(),
|
||||
assetId:
|
||||
String(persistedWorkflow.selectedBrandModel.assetId || "").trim() || null,
|
||||
displayName:
|
||||
String(persistedWorkflow.selectedBrandModel.displayName || "").trim() || null,
|
||||
mimeType:
|
||||
String(persistedWorkflow.selectedBrandModel.mimeType || "").trim() || null,
|
||||
sortOrder: Number.isFinite(
|
||||
Number(persistedWorkflow.selectedBrandModel.sortOrder)
|
||||
)
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
999,
|
||||
Math.round(Number(persistedWorkflow.selectedBrandModel.sortOrder))
|
||||
)
|
||||
)
|
||||
: 0,
|
||||
}
|
||||
: null
|
||||
);
|
||||
setSelectedAgentPresetId(
|
||||
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
|
||||
);
|
||||
@ -359,6 +391,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
},
|
||||
materials: workflowMaterials,
|
||||
selections: workflowSelections,
|
||||
selectedBrandModel,
|
||||
selectedAgentPresetId: selectedAgentPresetId || null,
|
||||
currentStage: currentAgentStage,
|
||||
};
|
||||
@ -371,6 +404,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
currentAgentStage,
|
||||
getCanvasState,
|
||||
selectedAgentPresetId,
|
||||
selectedBrandModel,
|
||||
workflowInputs,
|
||||
workflowMaterials,
|
||||
workflowSelections,
|
||||
@ -396,6 +430,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
});
|
||||
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials));
|
||||
setWorkflowSelections(parsed.selections || {});
|
||||
setSelectedBrandModel(
|
||||
parsed.selectedBrandModel
|
||||
? {
|
||||
...parsed.selectedBrandModel,
|
||||
brandName: String(parsed.selectedBrandModel.brandName || "").trim(),
|
||||
id: String(parsed.selectedBrandModel.id || "").trim(),
|
||||
imageUrl: String(parsed.selectedBrandModel.imageUrl || "").trim(),
|
||||
assetId: String(parsed.selectedBrandModel.assetId || "").trim() || null,
|
||||
displayName: String(parsed.selectedBrandModel.displayName || "").trim() || null,
|
||||
mimeType: String(parsed.selectedBrandModel.mimeType || "").trim() || null,
|
||||
sortOrder: Number.isFinite(Number(parsed.selectedBrandModel.sortOrder))
|
||||
? Math.max(0, Math.min(999, Math.round(Number(parsed.selectedBrandModel.sortOrder))))
|
||||
: 0,
|
||||
}
|
||||
: null
|
||||
);
|
||||
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
|
||||
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
|
||||
} catch (error) {
|
||||
@ -415,6 +465,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
inputs: workflowInputs,
|
||||
materials: workflowMaterials,
|
||||
selections: workflowSelections,
|
||||
selectedBrandModel,
|
||||
selectedAgentPresetId,
|
||||
currentAgentStage,
|
||||
})
|
||||
@ -428,10 +479,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
workflowMaterials,
|
||||
workflowSelections,
|
||||
workflowStorageKey,
|
||||
selectedBrandModel,
|
||||
selectedAgentPresetId,
|
||||
currentAgentStage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBrandModel) return;
|
||||
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
|
||||
if (!normalizedBrand) {
|
||||
setSelectedBrandModel(null);
|
||||
return;
|
||||
}
|
||||
if (selectedBrandModel.brandName.toLowerCase() !== normalizedBrand) {
|
||||
setSelectedBrandModel(null);
|
||||
}
|
||||
}, [selectedBrandModel, workflowInputs.brand]);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
@ -863,9 +927,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||
inputs: workflowInputs,
|
||||
materials: workflowMaterials,
|
||||
selectedBrandModel,
|
||||
selections: workflowSelections,
|
||||
});
|
||||
}, [resolvedWorkflowUiConfig, workflowInputs, workflowMaterials, workflowSelections]);
|
||||
}, [
|
||||
resolvedWorkflowUiConfig,
|
||||
selectedBrandModel,
|
||||
workflowInputs,
|
||||
workflowMaterials,
|
||||
workflowSelections,
|
||||
]);
|
||||
const workflowProgress = useMemo(
|
||||
() =>
|
||||
getAgentWorkflowProgress({
|
||||
@ -951,6 +1022,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
});
|
||||
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
|
||||
setWorkflowSelections({});
|
||||
setSelectedBrandModel(null);
|
||||
setSelectedAgentPresetId(null);
|
||||
setCurrentAgentStage("workflow");
|
||||
}}
|
||||
@ -970,7 +1042,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
生图模式
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
先在本页选择生图模式,再使用上方 SKU / SPU 取图,右侧全屏工作区会自动进入对应图片槽位并展示结果。
|
||||
先在本页选择生图模式,再按品牌选择模特图并搜索 SKU / SPU 图片,右侧全屏工作区会自动进入对应图片槽位并展示结果。
|
||||
</div>
|
||||
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
|
||||
第 2 步 / 2 步
|
||||
@ -994,13 +1066,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-4 p-4">
|
||||
<AgentWorkflowBrandModelPicker
|
||||
brand={workflowInputs.brand}
|
||||
selectedBrandModel={selectedBrandModel}
|
||||
onSelectBrandModel={setSelectedBrandModel}
|
||||
/>
|
||||
<AgentWorkflowMaterialSelectionPanel
|
||||
inputs={workflowInputs}
|
||||
onInputsChange={setWorkflowInputs}
|
||||
materials={workflowMaterials}
|
||||
onMaterialsChange={setWorkflowMaterials}
|
||||
title="SKU / SPU 获取图片"
|
||||
description="第二步直接在这里搜索主 SKU / SPU 与搭配 SKU 图片,选中的图片会自动进入右侧对应产品槽位。"
|
||||
description="第二步直接在这里搜索主 SKU / SPU 与搭配 SKU 图片,选中的图片会自动进入右侧前两个产品槽位。"
|
||||
disabled={!selectedAgentPresetId}
|
||||
disabledHint="请先选择一个生图模式,系统才能把你选中的图片自动送入对应生图槽位。"
|
||||
/>
|
||||
@ -1059,6 +1136,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
chatId={`canvas-${id}`}
|
||||
projectId={id}
|
||||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||||
selectedBrandModel={selectedBrandModel}
|
||||
workflowContext={workflowContext}
|
||||
/>
|
||||
</ClientErrorBoundary>
|
||||
|
||||
@ -30,12 +30,17 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
|
||||
import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent";
|
||||
import type {
|
||||
AgentPreset,
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowSelectedBrandModel,
|
||||
} from "@/types/agent";
|
||||
|
||||
interface AgentGenerationWorkspaceProps {
|
||||
chatId: string;
|
||||
projectId: string;
|
||||
selectedAgentPresetId?: string | null;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
workflowContext?: AgentWorkflowComposerContext | null;
|
||||
}
|
||||
|
||||
@ -120,6 +125,7 @@ export function AgentGenerationWorkspace({
|
||||
chatId,
|
||||
projectId,
|
||||
selectedAgentPresetId,
|
||||
selectedBrandModel = null,
|
||||
workflowContext = null,
|
||||
}: AgentGenerationWorkspaceProps) {
|
||||
const rawChatId = useMemo(
|
||||
@ -131,8 +137,9 @@ export function AgentGenerationWorkspace({
|
||||
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
const generatedImageKeysRef = useRef<Set<string>>(new Set());
|
||||
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<PickedAttachment[]>([]);
|
||||
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
|
||||
const [slotUploading, setSlotUploading] = useState<number | null>(null);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
|
||||
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
|
||||
@ -279,6 +286,7 @@ export function AgentGenerationWorkspace({
|
||||
);
|
||||
const primaryImages = referenceImages.filter((image) => image.source === "primary");
|
||||
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
|
||||
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
|
||||
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
|
||||
image
|
||||
? {
|
||||
@ -291,21 +299,66 @@ export function AgentGenerationWorkspace({
|
||||
|
||||
nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]);
|
||||
nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]);
|
||||
nextSlots[2] = toAttachment(brandModelImages[0]);
|
||||
|
||||
setAttachments((current) => {
|
||||
const result: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: agentUploadSlots.length },
|
||||
(_, index) => current[index] || null
|
||||
);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (!result[index] && nextSlots[index]) {
|
||||
result[index] = nextSlots[index] as PickedAttachment;
|
||||
}
|
||||
}
|
||||
return result.filter((item): item is PickedAttachment => Boolean(item));
|
||||
return result;
|
||||
});
|
||||
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAgentUploadSlots) {
|
||||
lastSyncedBrandModelUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAttachment = selectedBrandModel?.imageUrl
|
||||
? {
|
||||
url: selectedBrandModel.imageUrl,
|
||||
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
|
||||
contentType: selectedBrandModel.mimeType || "image/png",
|
||||
assetId: selectedBrandModel.assetId || undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
setAttachments((current) => {
|
||||
const slots: Array<PickedAttachment | null> = Array.from(
|
||||
{ length: Math.max(agentUploadSlots.length, 3) },
|
||||
(_, index) => current[index] ?? null
|
||||
);
|
||||
const currentSlot = slots[2] ?? null;
|
||||
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
|
||||
|
||||
if (nextAttachment) {
|
||||
slots[2] = nextAttachment;
|
||||
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
|
||||
slots[2] = null;
|
||||
}
|
||||
|
||||
return slots;
|
||||
});
|
||||
|
||||
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
|
||||
}, [
|
||||
agentUploadSlots.length,
|
||||
hasAgentUploadSlots,
|
||||
selectedBrandModel?.assetId,
|
||||
selectedBrandModel?.brandName,
|
||||
selectedBrandModel?.displayName,
|
||||
selectedBrandModel?.id,
|
||||
selectedBrandModel?.imageUrl,
|
||||
selectedBrandModel?.mimeType,
|
||||
]);
|
||||
|
||||
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
|
||||
return messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
@ -383,10 +436,7 @@ export function AgentGenerationWorkspace({
|
||||
(_, index) => current[index] ?? null
|
||||
);
|
||||
slots[slotIndex] = attachment;
|
||||
while (slots.length > 0 && !slots[slots.length - 1]) {
|
||||
slots.pop();
|
||||
}
|
||||
return slots.filter((item): item is PickedAttachment => Boolean(item));
|
||||
return slots;
|
||||
});
|
||||
}, [agentUploadSlots.length]);
|
||||
|
||||
@ -435,7 +485,8 @@ export function AgentGenerationWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAgentUploadSlots && attachments.length < requiredUploadCount) {
|
||||
const uploadedAttachmentCount = attachments.filter(Boolean).length;
|
||||
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
|
||||
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
|
||||
return;
|
||||
}
|
||||
@ -452,6 +503,7 @@ export function AgentGenerationWorkspace({
|
||||
> = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
if (!attachment) return;
|
||||
parts.push({
|
||||
type: "file",
|
||||
mediaType: attachment.contentType || "image/jpeg",
|
||||
@ -462,14 +514,11 @@ export function AgentGenerationWorkspace({
|
||||
});
|
||||
|
||||
let textContent = input.trim();
|
||||
if (!textContent && workflowContext?.summaryText) {
|
||||
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
|
||||
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
|
||||
}
|
||||
if (!textContent && selectedAgentPreset) {
|
||||
textContent =
|
||||
selectedAgentPreset.type === "text"
|
||||
? `请按「${selectedAgentPreset.name}」输出内容`
|
||||
: `请按「${selectedAgentPreset.name}」模式处理产品图1、产品图2和模特图。`;
|
||||
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
|
||||
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
|
||||
}
|
||||
if (textContent) {
|
||||
parts.push({ type: "text", text: textContent });
|
||||
@ -550,7 +599,7 @@ export function AgentGenerationWorkspace({
|
||||
<div className="rounded-3xl border bg-muted/10 p-4">
|
||||
<div className="text-sm font-medium">图片槽位</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,模特图可手动上传。
|
||||
主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,品牌模特选择会自动进入第三个模特图槽位,你也可以手动替换。
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{agentUploadSlots.map((slot, index) => {
|
||||
|
||||
159
components/agents/AgentWorkflowBrandModelPicker.tsx
Normal file
159
components/agents/AgentWorkflowBrandModelPicker.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Check, ImageIcon, UserRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
||||
import type {
|
||||
AgentWorkflowBrandModelConfig,
|
||||
AgentWorkflowSelectedBrandModel,
|
||||
} from "@/types/agent";
|
||||
|
||||
interface AgentWorkflowBrandModelPickerProps {
|
||||
brand: string;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
onSelectBrandModel: (value: AgentWorkflowSelectedBrandModel | null) => void;
|
||||
}
|
||||
|
||||
export function AgentWorkflowBrandModelPicker({
|
||||
brand,
|
||||
selectedBrandModel = null,
|
||||
onSelectBrandModel,
|
||||
}: AgentWorkflowBrandModelPickerProps) {
|
||||
const { data } = useSWR<{ agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig }>(
|
||||
"/api/site-settings",
|
||||
fetcher
|
||||
);
|
||||
|
||||
const normalizedBrand = brand.trim();
|
||||
const config = useMemo(
|
||||
() => normalizeAgentWorkflowBrandModelConfig(data?.agentWorkflowBrandModelConfig),
|
||||
[data?.agentWorkflowBrandModelConfig]
|
||||
);
|
||||
const matchedEntry = useMemo(
|
||||
() =>
|
||||
config.entries.find(
|
||||
(entry) => entry.brandName.toLowerCase() === normalizedBrand.toLowerCase()
|
||||
) || null,
|
||||
[config.entries, normalizedBrand]
|
||||
);
|
||||
const images = matchedEntry?.images || [];
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
|
||||
<UserRound className="h-4 w-4 text-[#4f46e5]" />
|
||||
<span>品牌模特选择</span>
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[#5b6472]">
|
||||
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!normalizedBrand ? (
|
||||
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
|
||||
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
|
||||
先回到第一步选择品牌,第二步才会按品牌展示可选模特图。
|
||||
</div>
|
||||
) : images.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full bg-background px-2 py-1">品牌:{normalizedBrand}</span>
|
||||
<span className="rounded-full bg-background px-2 py-1">
|
||||
共 {images.length} 张模特图
|
||||
</span>
|
||||
{selectedBrandModel ? (
|
||||
<span className="rounded-full bg-background px-2 py-1">已选 1 张</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelectBrandModel(null)}
|
||||
disabled={!selectedBrandModel}
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
|
||||
{images.map((image) => {
|
||||
const selected =
|
||||
selectedBrandModel?.brandName === normalizedBrand &&
|
||||
selectedBrandModel?.id === image.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectBrandModel(
|
||||
selected
|
||||
? null
|
||||
: {
|
||||
...image,
|
||||
brandName: normalizedBrand,
|
||||
}
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-2xl border bg-background text-left transition-all",
|
||||
selected
|
||||
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
|
||||
: "border-border hover:border-primary/40 hover:shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-muted/40">
|
||||
<img
|
||||
src={image.imageUrl}
|
||||
alt={image.displayName || `${normalizedBrand} 模特图`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
|
||||
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||
品牌模特
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
|
||||
{image.displayName || `${normalizedBrand} 模特图`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 px-3 py-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{image.displayName || `${normalizedBrand} 模特图`}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
点击{selected ? "取消" : "选择"},自动进入模特图槽位
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
|
||||
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
|
||||
品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -149,7 +149,7 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
|
||||
<AgentWorkflowMaterialPicker
|
||||
title="搭配 SKU"
|
||||
description="搭配商品图会优先自动填充到“产品图2”;模特图仍保留手动上传。"
|
||||
description="搭配商品图会优先自动填充到“产品图2”;品牌模特图会在上方单独选择并进入模特图槽位。"
|
||||
value={inputs.secondarySku}
|
||||
placeholder="输入搭配 SKU"
|
||||
buttonLabel="搜索搭配图"
|
||||
|
||||
115
lib/agents/workflow-brand-model-config.ts
Normal file
115
lib/agents/workflow-brand-model-config.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type {
|
||||
AgentWorkflowBrandModelConfig,
|
||||
AgentWorkflowBrandModelEntry,
|
||||
AgentWorkflowBrandModelImage,
|
||||
} from "@/types/agent";
|
||||
|
||||
export const AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY = "agent_workflow_brand_model_config";
|
||||
|
||||
export const defaultAgentWorkflowBrandModelConfig: AgentWorkflowBrandModelConfig = {
|
||||
entries: [],
|
||||
};
|
||||
|
||||
function sanitizeText(value: unknown, maxLength: number) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeBrandModelImage(
|
||||
value: unknown,
|
||||
index: number
|
||||
): AgentWorkflowBrandModelImage | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const imageUrl = sanitizeText(raw.imageUrl, 2000);
|
||||
if (!imageUrl) return null;
|
||||
|
||||
const assetId = sanitizeText(raw.assetId, 128) || null;
|
||||
const id = sanitizeText(raw.id, 128) || assetId || `brand-model-${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
assetId,
|
||||
imageUrl,
|
||||
displayName: sanitizeText(raw.displayName, 255) || null,
|
||||
mimeType: sanitizeText(raw.mimeType, 128) || null,
|
||||
sortOrder: Number.isFinite(Number(raw.sortOrder))
|
||||
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
|
||||
: index,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBrandModelEntry(
|
||||
value: unknown,
|
||||
index: number
|
||||
): AgentWorkflowBrandModelEntry | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const brandName = sanitizeText(raw.brandName, 64);
|
||||
if (!brandName) return null;
|
||||
|
||||
const rawImages = Array.isArray(raw.images) ? raw.images : [];
|
||||
const images = rawImages
|
||||
.map((item, imageIndex) => normalizeBrandModelImage(item, imageIndex))
|
||||
.filter((item): item is AgentWorkflowBrandModelImage => Boolean(item))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.slice(0, 100);
|
||||
|
||||
return {
|
||||
brandName,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentWorkflowBrandModelConfig(
|
||||
value: unknown
|
||||
): AgentWorkflowBrandModelConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return defaultAgentWorkflowBrandModelConfig;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const rawEntries = Array.isArray(raw.entries) ? raw.entries : [];
|
||||
const entryMap = new Map<string, AgentWorkflowBrandModelEntry>();
|
||||
|
||||
rawEntries.forEach((item, index) => {
|
||||
const normalized = normalizeBrandModelEntry(item, index);
|
||||
if (!normalized) return;
|
||||
|
||||
const existing = entryMap.get(normalized.brandName);
|
||||
if (!existing) {
|
||||
entryMap.set(normalized.brandName, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageMap = new Map<string, AgentWorkflowBrandModelImage>();
|
||||
[...existing.images, ...normalized.images].forEach((image, imageIndex) => {
|
||||
const key = image.assetId || image.id || image.imageUrl || `image-${imageIndex + 1}`;
|
||||
if (!imageMap.has(key)) {
|
||||
imageMap.set(key, {
|
||||
...image,
|
||||
sortOrder: imageMap.size,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
entryMap.set(normalized.brandName, {
|
||||
brandName: normalized.brandName,
|
||||
images: Array.from(imageMap.values()),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
entries: Array.from(entryMap.values())
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
images: entry.images.slice().sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
}))
|
||||
.sort((left, right) => left.brandName.localeCompare(right.brandName, "zh-CN")),
|
||||
};
|
||||
}
|
||||
@ -3,6 +3,7 @@ import type {
|
||||
AgentWorkflowComposerContext,
|
||||
AgentWorkflowMaterialSelections,
|
||||
AgentWorkflowReferenceImage,
|
||||
AgentWorkflowSelectedBrandModel,
|
||||
AgentWorkflowStepKey,
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
@ -18,6 +19,7 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
config?: AgentWorkflowUiConfig | null;
|
||||
inputs: AgentWorkflowInputsValue;
|
||||
materials?: AgentWorkflowMaterialSelections | null;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
}): AgentWorkflowComposerContext | null {
|
||||
const config = params.config || defaultAgentWorkflowUiConfig;
|
||||
@ -43,6 +45,7 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
const referenceImages = getSelectedWorkflowReferenceImages({
|
||||
inputs: params.inputs,
|
||||
materials: params.materials,
|
||||
selectedBrandModel: params.selectedBrandModel,
|
||||
});
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
@ -68,6 +71,11 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
`搭配SKU图:${referenceImages.filter((item) => item.source === "secondary").length}张`
|
||||
);
|
||||
}
|
||||
if (referenceImages.some((item) => item.source === "brandModel")) {
|
||||
summaryParts.push(
|
||||
`品牌模特图:${referenceImages.filter((item) => item.source === "brandModel").length}张`
|
||||
);
|
||||
}
|
||||
selections.forEach((item) => {
|
||||
summaryParts.push(`${item.stepTitle}:${item.optionName}`);
|
||||
});
|
||||
@ -113,6 +121,18 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (referenceImages.some((item) => item.source === "brandModel")) {
|
||||
const labels = referenceImages
|
||||
.filter((item) => item.source === "brandModel")
|
||||
.map((item) => item.label)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
promptLines.push(
|
||||
`品牌模特图:已选${referenceImages.filter((item) => item.source === "brandModel").length}张${
|
||||
labels.length > 0 ? `(${labels.join("、")})` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
selections.forEach((item) => {
|
||||
promptLines.push(`${item.stepTitle}:${item.promptFragment || item.optionName}`);
|
||||
});
|
||||
@ -132,6 +152,7 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
export function getSelectedWorkflowReferenceImages(params: {
|
||||
inputs: AgentWorkflowInputsValue;
|
||||
materials?: AgentWorkflowMaterialSelections | null;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
}): AgentWorkflowReferenceImage[] {
|
||||
const materials = params.materials;
|
||||
if (!materials) return [];
|
||||
@ -173,6 +194,20 @@ export function getSelectedWorkflowReferenceImages(params: {
|
||||
return [
|
||||
...collect("primary", "主SPU", params.inputs.primarySku.trim()),
|
||||
...collect("secondary", "搭配SKU", params.inputs.secondarySku.trim()),
|
||||
...(params.selectedBrandModel?.imageUrl
|
||||
? [
|
||||
{
|
||||
id: params.selectedBrandModel.id,
|
||||
source: "brandModel" as const,
|
||||
sourceLabel: "品牌模特",
|
||||
label: params.selectedBrandModel.displayName || params.selectedBrandModel.brandName,
|
||||
name: params.selectedBrandModel.displayName || params.selectedBrandModel.brandName,
|
||||
url: params.selectedBrandModel.imageUrl,
|
||||
assetId: params.selectedBrandModel.assetId || null,
|
||||
mimeType: params.selectedBrandModel.mimeType || null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -53,7 +53,29 @@ export interface AgentWorkflowMaterialApiConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export type AgentWorkflowMaterialSource = "primary" | "secondary";
|
||||
export interface AgentWorkflowBrandModelImage {
|
||||
id: string;
|
||||
assetId?: string | null;
|
||||
imageUrl: string;
|
||||
displayName?: string | null;
|
||||
mimeType?: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AgentWorkflowBrandModelEntry {
|
||||
brandName: string;
|
||||
images: AgentWorkflowBrandModelImage[];
|
||||
}
|
||||
|
||||
export interface AgentWorkflowBrandModelConfig {
|
||||
entries: AgentWorkflowBrandModelEntry[];
|
||||
}
|
||||
|
||||
export interface AgentWorkflowSelectedBrandModel extends AgentWorkflowBrandModelImage {
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
export type AgentWorkflowMaterialSource = "primary" | "secondary" | "brandModel";
|
||||
|
||||
export interface AgentWorkflowMaterialImage {
|
||||
id: string;
|
||||
@ -130,6 +152,7 @@ export interface AgentWorkflowPersistedState {
|
||||
};
|
||||
materials?: AgentWorkflowMaterialSelections;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
selectedAgentPresetId?: string | null;
|
||||
currentStage?: "workflow" | "mode";
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user