feat: support dynamic agent workflow steps
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-17 22:55:20 +08:00
parent dae5f59417
commit 67da32512a
5 changed files with 305 additions and 80 deletions

View File

@ -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() {
</CardContent>
</Card>
{sortedSteps.map((step) => (
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={handleAddStep}>
<Plus className="mr-2 h-4 w-4" />
新增步骤
</Button>
</div>
{sortedSteps.length > 0 ? (
sortedSteps.map((step) => (
<Card key={step.key}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
步骤 Key:{step.key}
</div>
<div className="text-xs text-muted-foreground">
新增步骤后系统会自动生成唯一 Key,用于前台保存和读取选择结果。
</div>
</div>
<div className="space-y-2">
<Label>步骤标题</Label>
<Input
@ -568,7 +704,21 @@ export function AgentWorkflowSettingsClient() {
</div>
</div>
<div className="flex items-center gap-2 pt-8">
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={() => handleAddOption(step.key)}>
<Plus className="mr-1 h-4 w-4" />
新增选项
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleRemoveStep(step.key)}
>
<Trash2 className="mr-1 h-4 w-4" />
删除步骤
</Button>
<Label htmlFor={`workflow-step-${step.key}`}>启用</Label>
<Switch
id={`workflow-step-${step.key}`}
@ -585,7 +735,8 @@ export function AgentWorkflowSettingsClient() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{step.options.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{step.options
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
@ -603,6 +754,16 @@ export function AgentWorkflowSettingsClient() {
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => handleRemoveOption(step.key, option.key)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
删除选项
</Button>
<Label
htmlFor={`workflow-option-${step.key}-${option.key}`}
className="text-xs text-muted-foreground"
@ -745,10 +906,22 @@ export function AgentWorkflowSettingsClient() {
</div>
);
})}
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
当前步骤还没有选项,请先新增一个选项。
</div>
)}
</CardContent>
</Card>
))}
))
) : (
<Card>
<CardContent className="rounded-2xl border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">
还没有配置工作流步骤,点击上方“新增步骤”开始搭建第一步页面。
</CardContent>
</Card>
)}
</div>
)}
</div>

View File

@ -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({
</div>
</section>
{resolvedConfig.steps.map((step) => (
{resolvedConfig.steps.length > 0 ? (
resolvedConfig.steps.map((step) => (
<section key={step.key} className="space-y-3">
<div>
<div className="text-sm font-medium">{step.title}</div>
@ -273,7 +275,12 @@ export function AgentWorkflowPanel({
})}
</div>
</section>
))}
))
) : (
<section className="rounded-2xl border border-dashed bg-muted/10 px-4 py-8 text-center text-sm text-muted-foreground">
当前还没有启用的工作流步骤,请先到系统设置新增步骤或启用现有步骤。
</section>
)}
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
<div className="text-sm font-medium">当前工作流摘要</div>
@ -367,7 +374,11 @@ export function AgentWorkflowPanel({
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
<Button type="button" onClick={onNextStep} disabled={!progress.isComplete}>
<Button
type="button"
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
>
下一步:选择生图模式
<ArrowRight className="ml-2 h-4 w-4" />
</Button>

View File

@ -23,7 +23,10 @@ export function buildAgentWorkflowComposerContext(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): 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<Record<AgentWorkflowStepKey, string>>;
}) {
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());

View File

@ -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<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string>();
const normalizedSteps = defaultAgentWorkflowUiConfig.steps.map((defaultStep, stepIndex) => {
const matchedStep =
rawSteps.find(
(item) =>
item &&
typeof item === "object" &&
normalizeText((item as Record<string, unknown>).key, 64) === defaultStep.key
) || null;
const matchedStepRecord =
matchedStep && typeof matchedStep === "object"
? (matchedStep as Record<string, unknown>)
: 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<string, unknown>).key, 64) === defaultOption.key
) || null;
const matchedOptionRecord =
matchedOption && typeof matchedOption === "object"
? (matchedOption as Record<string, unknown>)
const normalizedSteps = sourceSteps
.map((item, stepIndex) => {
const stepRecord =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: 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<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(
(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: {

View File

@ -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;