"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { ArrowLeft, Image as ImageIcon, Loader2, Plus, Save, Sparkles, Trash2, Upload, } from "lucide-react"; import { defaultAgentWorkflowUiConfig, normalizeAgentWorkflowUiConfig, } from "@/lib/agents/workflow-ui-config"; import { defaultAgentWorkflowBrandModelConfig, normalizeAgentWorkflowBrandModelConfig, } from "@/lib/agents/workflow-brand-model-config"; import { defaultAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig, } from "@/lib/agents/workflow-material-config"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { AgentWorkflowBrandModelConfig, AgentWorkflowMaterialApiConfig, AgentWorkflowOptionUiConfig, AgentWorkflowStepUiConfig, AgentWorkflowUiConfig, } from "@/types/agent"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); function normalizeConfigOrdering(config: AgentWorkflowUiConfig): AgentWorkflowUiConfig { return { ...config, steps: config.steps.map((step, stepIndex) => ({ ...step, sortOrder: stepIndex, options: step.options.map((option, optionIndex) => ({ ...option, sortOrder: optionIndex, })), })), }; } function createWorkflowSlug(value: string, fallback: string) { const normalized = value .trim() .replace(/\s+/g, "_") .replace(/[^\w-]/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, "") .toLowerCase(); return normalized || fallback; } function ensureUniqueWorkflowKey(baseKey: string, existingKeys: string[]) { const normalizedBase = createWorkflowSlug(baseKey, "item"); const keySet = new Set(existingKeys); if (!keySet.has(normalizedBase)) { return normalizedBase; } let suffix = 2; let candidate = `${normalizedBase}_${suffix}`; while (keySet.has(candidate)) { suffix += 1; candidate = `${normalizedBase}_${suffix}`; } return candidate; } function createWorkflowOptionDraft( stepKey: string, optionIndex: number, existingOptionKeys: string[] ): AgentWorkflowOptionUiConfig { const optionKey = ensureUniqueWorkflowKey( `${stepKey}_option_${optionIndex + 1}`, existingOptionKeys ); return { key: optionKey, buttonName: `选项${optionIndex + 1}`, description: "", previewImageUrl: "", enabled: true, sortOrder: optionIndex, promptFragment: `请按选项${optionIndex + 1}执行。`, }; } function createWorkflowStepDraft( stepIndex: number, existingStepKeys: string[] ): AgentWorkflowStepUiConfig { const stepKey = ensureUniqueWorkflowKey(`step_${stepIndex + 1}`, existingStepKeys); return { key: stepKey, title: `新步骤${stepIndex + 1}`, description: "", enabled: true, sortOrder: stepIndex, options: [createWorkflowOptionDraft(stepKey, 0, [])], }; } interface AgentWorkflowSettingsClientProps { backHref?: string; backLabel?: string; } export function AgentWorkflowSettingsClient({ backHref = "/settings", backLabel = "返回设置", }: AgentWorkflowSettingsClientProps = {}) { const { data, isLoading, mutate } = useSWR<{ agentWorkflowUiConfig?: AgentWorkflowUiConfig; agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig; agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig; }>("/api/site-settings", fetcher); const [config, setConfig] = useState(defaultAgentWorkflowUiConfig); const [materialApiConfig, setMaterialApiConfig] = useState( defaultAgentWorkflowMaterialApiConfig ); const [brandModelConfig, setBrandModelConfig] = useState( defaultAgentWorkflowBrandModelConfig ); const [isSaving, setIsSaving] = useState(false); const [uploadingKey, setUploadingKey] = useState(null); const fileInputRefs = useRef>({}); useEffect(() => { if (!data) return; setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig)); 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] ); const updateStep = ( stepKey: string, updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number] ) => { setConfig((prev) => normalizeConfigOrdering({ ...prev, steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)), }) ); }; const updateOption = ( stepKey: string, optionKey: string, updater: ( option: AgentWorkflowUiConfig["steps"][number]["options"][number] ) => AgentWorkflowUiConfig["steps"][number]["options"][number] ) => { updateStep(stepKey, (step) => ({ ...step, options: step.options.map((option) => option.key === optionKey ? updater(option) : option ), })); }; const handleAddStep = () => { setConfig((prev) => normalizeConfigOrdering({ ...prev, steps: [ ...prev.steps, createWorkflowStepDraft(prev.steps.length, prev.steps.map((step) => step.key)), ], }) ); }; const handleRemoveStep = (stepKey: string) => { setConfig((prev) => normalizeConfigOrdering({ ...prev, steps: prev.steps.filter((step) => step.key !== stepKey), }) ); }; const handleAddOption = (stepKey: string) => { updateStep(stepKey, (step) => ({ ...step, options: [ ...step.options, createWorkflowOptionDraft(step.key, step.options.length, step.options.map((item) => item.key)), ], })); }; const handleRemoveOption = (stepKey: string, optionKey: string) => { updateStep(stepKey, (step) => ({ ...step, options: step.options.filter((option) => option.key !== optionKey), })); }; const handleUploadPreview = async (stepKey: string, optionKey: string, file: File) => { if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); return; } const refKey = `${stepKey}:${optionKey}`; try { setUploadingKey(refKey); 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 || "上传失败"); } updateOption(stepKey, optionKey, (option) => ({ ...option, previewImageUrl: result.url, })); toast.success("预览图上传成功"); } catch (error) { toast.error(error instanceof Error ? error.message : "预览图上传失败"); } finally { setUploadingKey(null); } }; const handleSave = async () => { try { setIsSaving(true); const response = await fetch("/api/site-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agentWorkflowUiConfig: config, agentWorkflowMaterialApiConfig: materialApiConfig, agentWorkflowBrandModelConfig: brandModelConfig, }), }); if (!response.ok) { throw new Error("保存失败"); } toast.success("智能体工作流配置已保存"); await mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); } finally { setIsSaving(false); } }; const updateBrandModelEntry = ( brandName: string, updater: ( entry: NonNullable ) => NonNullable ) => { 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 (
智能体配置中心

智能体工作流配置

单独维护智能体页面左侧每个环节的功能按钮名称、说明、Prompt 片段和预览图。

功能说明 这里配置的是智能体工作流页面的功能卡片。网站设置页只保留入口,不再承载这些详细配置。 {isLoading ? ( ) : (
素材搜索接口配置 配置主 SPU / 搭配 SKU 搜图所需的接口地址与 API Key。Key 会按系统设置方式存储到数据库中,不会下发到前台页面。
启用素材搜索
开启后,智能体工作流页面支持搜索并选择白底素材图。
setMaterialApiConfig((prev) => ({ ...prev, enabled: checked, })) } />
setMaterialApiConfig((prev) => ({ ...prev, baseUrl: event.target.value, })) } placeholder="https://sc.jiansun.vip/api/external" />
setMaterialApiConfig((prev) => ({ ...prev, apiKey: event.target.value, })) } placeholder="请输入素材搜索 API Key" />
当前适配的是白底图搜索接口;前台不会直接暴露 Key,而是由服务端代查并转存图片。
基础下拉配置 工作流左侧“品牌 / 店铺”采用后台配置的候选列表,前台支持输入关键字匹配下拉选择。