From 67da32512a8255fc651cacca580816790a2f2935 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 22:55:20 +0800 Subject: [PATCH] feat: support dynamic agent workflow steps --- .../agent-workflow-settings-client.tsx | 191 +++++++++++++++++- components/agents/AgentWorkflowPanel.tsx | 19 +- lib/agents/workflow-composer.ts | 10 +- lib/agents/workflow-ui-config.ts | 155 +++++++++----- types/agent.ts | 10 +- 5 files changed, 305 insertions(+), 80 deletions(-) diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index c6ebe646c..efc216c39 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -8,8 +8,10 @@ import { ArrowLeft, Image as ImageIcon, Loader2, + Plus, Save, Sparkles, + Trash2, Upload, } from "lucide-react"; import { @@ -34,11 +36,89 @@ 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, [])], + }; +} + export function AgentWorkflowSettingsClient() { const { data, isLoading, mutate } = useSWR<{ agentWorkflowUiConfig?: AgentWorkflowUiConfig; @@ -89,10 +169,12 @@ export function AgentWorkflowSettingsClient() { stepKey: string, updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number] ) => { - setConfig((prev) => ({ - ...prev, - steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)), - })); + setConfig((prev) => + normalizeConfigOrdering({ + ...prev, + steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)), + }) + ); }; const updateOption = ( @@ -110,6 +192,44 @@ export function AgentWorkflowSettingsClient() { })); }; + 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("请上传图片文件"); @@ -534,11 +654,27 @@ export function AgentWorkflowSettingsClient() { - {sortedSteps.map((step) => ( +
+ +
+ + {sortedSteps.length > 0 ? ( + sortedSteps.map((step) => (
+
+
+ 步骤 Key:{step.key} +
+
+ 新增步骤后系统会自动生成唯一 Key,用于前台保存和读取选择结果。 +
+
-
+
+ + -
+ {step.options.length > 0 ? ( +
{step.options .slice() .sort((left, right) => left.sortOrder - right.sortOrder) @@ -603,6 +754,16 @@ export function AgentWorkflowSettingsClient() {
+
+
+ ) : ( +
+ 当前步骤还没有选项,请先新增一个选项。 +
+ )} - ))} + )) + ) : ( + + + 还没有配置工作流步骤,点击上方“新增步骤”开始搭建第一步页面。 + + + )}
)}
diff --git a/components/agents/AgentWorkflowPanel.tsx b/components/agents/AgentWorkflowPanel.tsx index 3677f2736..fcfc0cdf3 100644 --- a/components/agents/AgentWorkflowPanel.tsx +++ b/components/agents/AgentWorkflowPanel.tsx @@ -89,7 +89,7 @@ export function AgentWorkflowPanel({ [config?.inputOptions?.stores] ); const resolvedConfig = useMemo(() => { - const source = config?.steps?.length ? config : defaultAgentWorkflowUiConfig; + const source = config || defaultAgentWorkflowUiConfig; return { steps: [...source.steps] .filter((step) => step.enabled) @@ -99,6 +99,7 @@ export function AgentWorkflowPanel({ .filter((option) => option.enabled) .sort((left, right) => left.sortOrder - right.sortOrder), })) + .filter((step) => step.options.length > 0) .sort((left, right) => left.sortOrder - right.sortOrder), }; }, [config]); @@ -217,7 +218,8 @@ export function AgentWorkflowPanel({
- {resolvedConfig.steps.map((step) => ( + {resolvedConfig.steps.length > 0 ? ( + resolvedConfig.steps.map((step) => (
{step.title}
@@ -273,7 +275,12 @@ export function AgentWorkflowPanel({ })}
- ))} + )) + ) : ( +
+ 当前还没有启用的工作流步骤,请先到系统设置新增步骤或启用现有步骤。 +
+ )}
当前工作流摘要
@@ -367,7 +374,11 @@ export function AgentWorkflowPanel({ ) : null} - diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index 791dbe821..1f9e90b99 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -23,7 +23,10 @@ export function buildAgentWorkflowComposerContext(params: { selections: Partial>; }): AgentWorkflowComposerContext | null { const config = params.config || defaultAgentWorkflowUiConfig; - const steps = Array.isArray(config.steps) ? config.steps : []; + const steps = (Array.isArray(config.steps) ? config.steps : []).filter( + (step) => + step.enabled !== false && Array.isArray(step.options) && step.options.some((item) => item.enabled !== false) + ); const selections = steps .filter((step) => step.enabled !== false) .map((step) => { @@ -216,7 +219,10 @@ export function getAgentWorkflowProgress(params: { selections: Partial>; }) { const config = params.config || defaultAgentWorkflowUiConfig; - const enabledSteps = (config.steps || []).filter((step) => step.enabled !== false); + const enabledSteps = (config.steps || []).filter( + (step) => + step.enabled !== false && Array.isArray(step.options) && step.options.some((item) => item.enabled !== false) + ); const completedSteps = enabledSteps.filter((step) => { const value = params.selections[step.key]; return Boolean(String(value || "").trim()); diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index aa5da024e..895f7aebe 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -1,6 +1,5 @@ import type { AgentWorkflowOptionUiConfig, - AgentWorkflowStepKey, AgentWorkflowStepUiConfig, AgentWorkflowUiConfig, } from "@/types/agent"; @@ -27,7 +26,7 @@ function cloneStep(step: AgentWorkflowStepUiConfig): AgentWorkflowStepUiConfig { } function createStep( - key: AgentWorkflowStepKey, + key: string, title: string, description: string, options: AgentWorkflowOptionUiConfig[] @@ -134,6 +133,18 @@ function normalizeText(value: unknown, maxLength: number) { return String(value || "").trim().slice(0, maxLength); } +function normalizeKey(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 normalizeBoolean(value: unknown, fallback: boolean) { return typeof value === "boolean" ? value : fallback; } @@ -149,6 +160,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + const hasRawSteps = Array.isArray(raw.steps); const rawSteps = Array.isArray(raw.steps) ? raw.steps : []; const normalizeStringList = (input: unknown) => { if (!Array.isArray(input)) return [] as string[]; @@ -165,68 +177,99 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC raw.inputOptions && typeof raw.inputOptions === "object" && !Array.isArray(raw.inputOptions) ? (raw.inputOptions as Record) : {}; + const defaultStepByKey = new Map( + defaultAgentWorkflowUiConfig.steps.map((step) => [step.key, step] as const) + ); + const sourceSteps = hasRawSteps ? rawSteps : (defaultAgentWorkflowUiConfig.steps as unknown[]); + const usedStepKeys = new Set(); - const normalizedSteps = defaultAgentWorkflowUiConfig.steps.map((defaultStep, stepIndex) => { - const matchedStep = - rawSteps.find( - (item) => - item && - typeof item === "object" && - normalizeText((item as Record).key, 64) === defaultStep.key - ) || null; - const matchedStepRecord = - matchedStep && typeof matchedStep === "object" - ? (matchedStep as Record) - : null; - const rawOptions = Array.isArray(matchedStepRecord?.options) - ? (matchedStepRecord?.options as unknown[]) - : []; - - const options = defaultStep.options.map((defaultOption, optionIndex) => { - const matchedOption = - rawOptions.find( - (item) => - item && - typeof item === "object" && - normalizeText((item as Record).key, 64) === defaultOption.key - ) || null; - const matchedOptionRecord = - matchedOption && typeof matchedOption === "object" - ? (matchedOption as Record) + const normalizedSteps = sourceSteps + .map((item, stepIndex) => { + const stepRecord = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) : null; + const candidateKey = normalizeKey(stepRecord?.key, `step_${stepIndex + 1}`); + let stepKey = candidateKey; + let duplicateIndex = 2; + while (usedStepKeys.has(stepKey)) { + stepKey = `${candidateKey}_${duplicateIndex}`; + duplicateIndex += 1; + } + usedStepKeys.add(stepKey); + + const defaultStep = defaultStepByKey.get(stepKey); + const fallbackTitle = defaultStep?.title || `步骤${stepIndex + 1}`; + const fallbackDescription = defaultStep?.description || ""; + const hasRawOptions = Array.isArray(stepRecord?.options); + const rawOptions = hasRawOptions ? (stepRecord?.options as unknown[]) : []; + const defaultOptions = + !hasRawOptions && defaultStep?.options ? defaultStep.options : []; + const optionSource = rawOptions.length > 0 || hasRawOptions ? rawOptions : defaultOptions; + const usedOptionKeys = new Set(); + + const options = optionSource + .map((option, optionIndex) => { + const optionRecord = + option && typeof option === "object" && !Array.isArray(option) + ? (option as Record) + : null; + const defaultOption = defaultStep?.options.find( + (item) => item.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, + }; + }) + .sort((left, right) => left.sortOrder - right.sortOrder); return { - key: defaultOption.key, - buttonName: - normalizeText(matchedOptionRecord?.buttonName, 64) || defaultOption.buttonName, + key: stepKey, + title: normalizeText(stepRecord?.title, 64) || fallbackTitle, description: - normalizeText(matchedOptionRecord?.description, 300) || defaultOption.description || "", - previewImageUrl: normalizeText(matchedOptionRecord?.previewImageUrl, 1000), - enabled: normalizeBoolean(matchedOptionRecord?.enabled, defaultOption.enabled), + normalizeText(stepRecord?.description, 300) || fallbackDescription, + enabled: normalizeBoolean(stepRecord?.enabled, defaultStep?.enabled ?? true), sortOrder: normalizeNumber( - matchedOptionRecord?.sortOrder, - defaultOption.sortOrder ?? optionIndex + stepRecord?.sortOrder, + defaultStep?.sortOrder ?? stepIndex ), - promptFragment: - normalizeText(matchedOptionRecord?.promptFragment, 1000) || - defaultOption.promptFragment || - "", + options, }; - }); - - return { - key: defaultStep.key, - title: normalizeText(matchedStepRecord?.title, 64) || defaultStep.title, - description: - normalizeText(matchedStepRecord?.description, 300) || defaultStep.description || "", - enabled: normalizeBoolean(matchedStepRecord?.enabled, defaultStep.enabled), - sortOrder: normalizeNumber( - matchedStepRecord?.sortOrder, - defaultStep.sortOrder ?? stepIndex - ), - options, - }; - }); + }) + .sort((left, right) => left.sortOrder - right.sortOrder); return { inputOptions: { diff --git a/types/agent.ts b/types/agent.ts index 174c5f13b..a8b27d4ca 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -10,15 +10,7 @@ export interface AgentCategoryPath { brand?: string | null; } -export type AgentWorkflowStepKey = - | "audience" - | "style" - | "space" - | "subSpace" - | "hardDecoration" - | "softDecoration" - | "lighting" - | "accessories"; +export type AgentWorkflowStepKey = string; export interface AgentWorkflowOptionUiConfig { key: string;