feat: refine agent workflow settings and studio ui
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 00:34:09 +08:00
parent a4abda497c
commit 7ebfcfd4eb
7 changed files with 726 additions and 516 deletions

View File

@ -26,7 +26,13 @@ import {
Video,
} from "lucide-react";
import {
AGENT_WORKFLOW_MAX_TREE_DEPTH,
countAgentWorkflowOptions,
countAgentWorkflowSteps,
defaultAgentWorkflowUiConfig,
findAgentWorkflowStepByKey,
flattenAgentWorkflowSteps,
normalizeAgentWorkflowStepOrdering,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
@ -78,17 +84,12 @@ const itemVariants = {
visible: { opacity: 1, y: 0 }
};
const WORKFLOW_LEVEL_LABELS = ["一级模块", "二级模块", "三级模块"];
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,
})),
})),
steps: normalizeAgentWorkflowStepOrdering(config.steps),
};
}
@ -140,20 +141,59 @@ function createWorkflowOptionDraft(
}
function createWorkflowStepDraft(
depth: number,
stepIndex: number,
existingStepKeys: string[]
): AgentWorkflowStepUiConfig {
const stepKey = ensureUniqueWorkflowKey(`step_${stepIndex + 1}`, existingStepKeys);
const levelLabel = WORKFLOW_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, 2))] || "模块";
const stepKey = ensureUniqueWorkflowKey(`step_${depth}_${stepIndex + 1}`, existingStepKeys);
return {
key: stepKey,
title: `新步骤${stepIndex + 1}`,
title: `新${levelLabel}${stepIndex + 1}`,
description: "",
enabled: true,
sortOrder: stepIndex,
options: [createWorkflowOptionDraft(stepKey, 0, [])],
children: [],
};
}
function updateWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string,
updater: (step: AgentWorkflowStepUiConfig) => AgentWorkflowStepUiConfig
): AgentWorkflowStepUiConfig[] {
return steps.map((step) => {
if (step.key === stepKey) {
return updater(step);
}
return {
...step,
children: updateWorkflowStepTree(step.children || [], stepKey, updater),
};
});
}
function removeWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[],
stepKey: string
): AgentWorkflowStepUiConfig[] {
return steps
.filter((step) => step.key !== stepKey)
.map((step) => ({
...step,
children: removeWorkflowStepTree(step.children || [], stepKey),
}));
}
function collectWorkflowStepKeys(steps: AgentWorkflowStepUiConfig[]): string[] {
return flattenAgentWorkflowSteps(steps).map(({ step }) => step.key);
}
function collectDescendantStepKeys(step: AgentWorkflowStepUiConfig): string[] {
return [step.key, ...collectWorkflowStepKeys(step.children || [])];
}
interface AgentWorkflowSettingsClientProps {
backHref?: string;
backLabel?: string;
@ -189,7 +229,8 @@ export function AgentWorkflowSettingsClient({
useEffect(() => {
if (!data) return;
setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig));
const normalizedUiConfig = normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig);
setConfig(normalizedUiConfig);
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
@ -199,9 +240,8 @@ export function AgentWorkflowSettingsClient({
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
// Expand first step by default
if (data.agentWorkflowUiConfig?.steps?.[0]) {
setExpandedSteps(new Set([data.agentWorkflowUiConfig.steps[0].key]));
if (normalizedUiConfig.steps[0]) {
setExpandedSteps(new Set([normalizedUiConfig.steps[0].key]));
}
}, [data]);
@ -278,11 +318,15 @@ export function AgentWorkflowSettingsClient({
};
const enabledStepsCount = useMemo(
() => sortedSteps.filter((s) => s.enabled).length,
() => flattenAgentWorkflowSteps(sortedSteps).filter(({ step }) => step.enabled).length,
[sortedSteps]
);
const totalOptionsCount = useMemo(
() => sortedSteps.reduce((acc, s) => acc + s.options.filter((o) => o.enabled).length, 0),
() =>
flattenAgentWorkflowSteps(sortedSteps).reduce(
(acc, { step }) => acc + step.options.filter((option) => option.enabled).length,
0
),
[sortedSteps]
);
@ -305,7 +349,7 @@ export function AgentWorkflowSettingsClient({
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)),
steps: updateWorkflowStepTree(prev.steps, stepKey, updater),
})
);
};
@ -325,30 +369,59 @@ export function AgentWorkflowSettingsClient({
}));
};
const handleAddStep = () => {
const handleAddStep = (parentStepKey?: string) => {
setConfig((prev) => {
const newStep = createWorkflowStepDraft(prev.steps.length, prev.steps.map((step) => step.key));
// Auto expand new step
setExpandedSteps((prevExpanded) => new Set([...prevExpanded, newStep.key]));
const existingStepKeys = collectWorkflowStepKeys(prev.steps);
if (!parentStepKey) {
const newStep = createWorkflowStepDraft(1, prev.steps.length, existingStepKeys);
setExpandedSteps((prevExpanded) => new Set([...prevExpanded, newStep.key]));
return normalizeConfigOrdering({
...prev,
steps: [...prev.steps, newStep],
});
}
const parentStep = findAgentWorkflowStepByKey(prev.steps, parentStepKey);
const parentDepth = flattenAgentWorkflowSteps(prev.steps).find(
({ step }) => step.key === parentStepKey
)?.depth;
if (!parentStep || !parentDepth || parentDepth >= AGENT_WORKFLOW_MAX_TREE_DEPTH) {
return prev;
}
const newChildStep = createWorkflowStepDraft(
parentDepth + 1,
(parentStep.children || []).length,
existingStepKeys
);
setExpandedSteps(
(prevExpanded) => new Set([...prevExpanded, parentStepKey, newChildStep.key])
);
return normalizeConfigOrdering({
...prev,
steps: [...prev.steps, newStep],
steps: updateWorkflowStepTree(prev.steps, parentStepKey, (step) => ({
...step,
children: [...(step.children || []), newChildStep],
})),
});
});
// Switch to workflow tab
setActiveTab("workflow");
};
const handleRemoveStep = (stepKey: string) => {
const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey);
setConfig((prev) =>
normalizeConfigOrdering({
...prev,
steps: prev.steps.filter((step) => step.key !== stepKey),
steps: removeWorkflowStepTree(prev.steps, stepKey),
})
);
setExpandedSteps((prev) => {
const next = new Set(prev);
next.delete(stepKey);
for (const key of targetStep ? collectDescendantStepKeys(targetStep) : [stepKey]) {
next.delete(key);
}
return next;
});
};
@ -428,6 +501,332 @@ export function AgentWorkflowSettingsClient({
}
};
const renderWorkflowStepCard = (
step: AgentWorkflowStepUiConfig,
depth: number,
indexPath: number[]
) => {
const stepLabel = indexPath.join(".");
const levelLabel =
WORKFLOW_LEVEL_LABELS[Math.max(0, Math.min(depth - 1, WORKFLOW_LEVEL_LABELS.length - 1))] ||
`第 ${depth} 级模块`;
const childSteps = [...(step.children || [])].sort((left, right) => left.sortOrder - right.sortOrder);
const directEnabledOptionCount = step.options.filter((option) => option.enabled).length;
const childStepCount = countAgentWorkflowSteps(childSteps);
const canAddChild = depth < AGENT_WORKFLOW_MAX_TREE_DEPTH;
return (
<motion.div
key={step.key}
variants={itemVariants}
className={cn(depth > 1 && "pl-4 sm:pl-6")}
>
<Collapsible
open={expandedSteps.has(step.key)}
onOpenChange={() => toggleStepExpansion(step.key)}
>
<Card
className={cn(
!step.enabled && "opacity-60",
depth > 1 && "border-l-4 border-l-emerald-500/30"
)}
>
<CardHeader className="py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center gap-4">
<CollapsibleTrigger asChild>
<button className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted">
{expandedSteps.has(step.key) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<div className="flex h-9 min-w-9 items-center justify-center rounded-md bg-emerald-500/10 px-2 text-xs font-medium text-emerald-600">
{stepLabel}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Input
value={step.title}
onChange={(e) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
title: e.target.value,
}))
}
className="h-8 min-w-[180px] max-w-[260px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0"
placeholder="模块标题"
/>
<Badge variant="outline">{levelLabel}</Badge>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{step.key}
</code>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{directEnabledOptionCount} 选项</Badge>
<Badge variant="outline">{childStepCount} 子模块</Badge>
{canAddChild ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddStep(step.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增子级
</Button>
) : (
<Badge variant="outline">已达三级</Badge>
)}
<Switch
checked={step.enabled}
onCheckedChange={(checked) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
enabled: checked,
}))
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveStep(step.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="border-t bg-muted/20 pt-4">
<div className="mb-4 space-y-2">
<Label className="text-xs">模块说明</Label>
<Textarea
value={step.description || ""}
onChange={(e) =>
updateStep(step.key, (currentStep) => ({
...currentStep,
description: e.target.value,
}))
}
rows={2}
placeholder="描述此模块的用途..."
className="bg-background"
/>
</div>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium">选项配置</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddOption(step.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增选项
</Button>
</div>
{step.options.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{step.options
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((option) => {
const refKey = `${step.key}:${option.key}`;
const isUploading = uploadingKey === refKey;
return (
<div
key={option.key}
className={cn(
"rounded-lg border bg-card p-4",
!option.enabled && "opacity-60"
)}
>
<div className="mb-4 flex items-start justify-between gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{option.key}
</code>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleRemoveOption(step.key, option.key)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
enabled: checked,
}))
}
className="scale-75"
/>
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-4">
<div className="space-y-2">
<div className="aspect-square overflow-hidden rounded-lg border bg-muted">
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
<ImageIcon className="h-6 w-6 opacity-40" />
<span className="text-[10px]">无图片</span>
</div>
)}
</div>
<input
ref={(el) => {
fileInputRefs.current[refKey] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void handleUploadPreview(step.key, option.key, file);
}
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full text-xs"
disabled={isUploading}
onClick={() => fileInputRefs.current[refKey]?.click()}
>
{isUploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
上传
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">按钮名称</Label>
<Input
value={option.buttonName}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
buttonName: e.target.value,
}))
}
placeholder="选项名称"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">说明文案</Label>
<Input
value={option.description || ""}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
description: e.target.value,
}))
}
placeholder="简短描述"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Prompt 片段</Label>
<Textarea
value={option.promptFragment || ""}
onChange={(e) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
promptFragment: e.target.value,
}))
}
placeholder="注入的提示词片段"
rows={2}
className="resize-none text-xs"
/>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
暂无选项,点击上方按钮添加
</div>
)}
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">子模块配置</div>
<div className="text-xs text-muted-foreground">
每个模块最多支持三级树形结构,可继续向下建立母子模块关系。
</div>
</div>
{canAddChild ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddStep(step.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增子级
</Button>
) : null}
</div>
{childSteps.length > 0 ? (
<div className="space-y-4 border-l border-dashed border-border/80 pl-3 sm:pl-4">
{childSteps.map((childStep, childIndex) =>
renderWorkflowStepCard(childStep, depth + 1, [...indexPath, childIndex + 1])
)}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
暂无子模块
</div>
)}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</motion.div>
);
};
const updateBrandModelEntry = (
brandName: string,
updater: (
@ -1277,258 +1676,22 @@ export function AgentWorkflowSettingsClient({
<motion.div variants={itemVariants} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
{sortedSteps.length} 步骤
{countAgentWorkflowSteps(sortedSteps)} 模块
</Badge>
<Badge variant="outline" className="font-mono">
{sortedSteps.reduce((acc, s) => acc + s.options.length, 0)} 选项
{countAgentWorkflowOptions(sortedSteps)} 选项
</Badge>
</div>
<Button onClick={handleAddStep} size="sm">
<Button onClick={() => handleAddStep()} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增步骤
新增一级模块
</Button>
</motion.div>
{sortedSteps.length > 0 ? (
sortedSteps.map((step, stepIndex) => (
<motion.div key={step.key} variants={itemVariants}>
<Collapsible
open={expandedSteps.has(step.key)}
onOpenChange={() => toggleStepExpansion(step.key)}
>
<Card className={cn(!step.enabled && "opacity-60")}>
<CardHeader className="py-4">
<div className="flex items-center gap-4">
<CollapsibleTrigger asChild>
<button className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted">
{expandedSteps.has(step.key) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-violet-500/10 text-xs font-medium text-violet-600">
{stepIndex + 1}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Input
value={step.title}
onChange={(e) =>
updateStep(step.key, (s) => ({ ...s, title: e.target.value }))
}
className="h-8 w-[200px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0"
placeholder="步骤标题"
/>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{step.key}
</code>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{step.options.filter((o) => o.enabled).length} 选项
</Badge>
<Switch
checked={step.enabled}
onCheckedChange={(checked) =>
updateStep(step.key, (s) => ({ ...s, enabled: checked }))
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveStep(step.key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="border-t bg-muted/20 pt-4">
<div className="mb-4 space-y-2">
<Label className="text-xs">步骤说明</Label>
<Textarea
value={step.description || ""}
onChange={(e) =>
updateStep(step.key, (s) => ({ ...s, description: e.target.value }))
}
rows={2}
placeholder="描述此步骤的用途..."
className="bg-background"
/>
</div>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium">选项配置</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddOption(step.key)}
>
<Plus className="mr-2 h-4 w-4" />
新增选项
</Button>
</div>
{step.options.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{step.options
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((option) => {
const refKey = `${step.key}:${option.key}`;
const isUploading = uploadingKey === refKey;
return (
<div
key={option.key}
className={cn(
"rounded-lg border bg-card p-4",
!option.enabled && "opacity-60"
)}
>
<div className="mb-4 flex items-start justify-between gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{option.key}
</code>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleRemoveOption(step.key, option.key)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (o) => ({
...o,
enabled: checked,
}))
}
className="scale-75"
/>
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-4">
<div className="space-y-2">
<div className="aspect-square overflow-hidden rounded-lg border bg-muted">
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
<ImageIcon className="h-6 w-6 opacity-40" />
<span className="text-[10px]">无图片</span>
</div>
)}
</div>
<input
ref={(el) => { fileInputRefs.current[refKey] = el; }}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadPreview(step.key, option.key, file);
e.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full text-xs"
disabled={isUploading}
onClick={() => fileInputRefs.current[refKey]?.click()}
>
{isUploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
上传
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">按钮名称</Label>
<Input
value={option.buttonName}
onChange={(e) =>
updateOption(step.key, option.key, (o) => ({
...o,
buttonName: e.target.value,
}))
}
placeholder="选项名称"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">说明文案</Label>
<Input
value={option.description || ""}
onChange={(e) =>
updateOption(step.key, option.key, (o) => ({
...o,
description: e.target.value,
}))
}
placeholder="简短描述"
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Prompt 片段</Label>
<Textarea
value={option.promptFragment || ""}
onChange={(e) =>
updateOption(step.key, option.key, (o) => ({
...o,
promptFragment: e.target.value,
}))
}
placeholder="注入的提示词片段"
rows={2}
className="resize-none text-xs"
/>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
暂无选项,点击上方按钮添加
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</motion.div>
))
sortedSteps.map((step, stepIndex) =>
renderWorkflowStepCard(step, 1, [stepIndex + 1])
)
) : (
<motion.div variants={itemVariants}>
<Card>
@ -1536,9 +1699,9 @@ export function AgentWorkflowSettingsClient({
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="mt-4 text-lg font-medium">还没有配置工作流步骤</p>
<p className="mt-4 text-lg font-medium">还没有配置工作流模块</p>
<p className="text-sm text-muted-foreground">
点击上方按钮开始创建第一步
点击上方按钮开始创建第一个一级模块
</p>
</CardContent>
</Card>

View File

@ -1329,95 +1329,6 @@ export function AgentGenerationWorkspaceDarkV2({
{/* Main Content */}
<main className="flex-1 overflow-y-auto overscroll-contain pt-16">
<div className="mx-auto flex w-full max-w-[1480px] flex-col gap-6 px-6 py-6 pb-24 lg:px-8 xl:px-10">
<motion.section
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-[26px] border border-white/12 bg-white/[0.04] px-4 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] lg:px-5"
>
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-[10px] uppercase tracking-[0.22em] text-white/40">
Step Flow
</span>
<h2 className="text-[15px] font-semibold leading-none text-white lg:text-[18px]">
第二步:生图模式执行流程
</h2>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] text-white/58">
第一步:风格选择
<span className="mx-1.5 text-white/22">→</span>
<span className="font-medium text-emerald-300">第二步:生图模式</span>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] text-white/58">
当前步骤 {activeToolStepIndex + 1} / {toolSteps.length}
</div>
</div>
</div>
<div className="agent-dark-scrollbar mt-2 overflow-x-auto pb-0.5">
<div className="flex min-w-max items-center gap-2">
{toolSteps.map((step, index) => {
const Icon = step.icon;
const isActive = activeToolStep === step.key;
return (
<Fragment key={step.key}>
<button
type="button"
onClick={() => handleToolStepChange(step.key)}
disabled={step.disabled}
className={cn(
"flex h-[58px] min-w-[148px] items-center gap-2 rounded-[20px] border px-2.5 py-2 text-left transition-all lg:min-w-[158px]",
isActive
? "border-emerald-400/55 bg-emerald-500/[0.10] shadow-[0_0_0_1px_rgba(16,185,129,0.2)]"
: step.completed
? "border-emerald-400/30 bg-white/[0.03] hover:border-emerald-400/45"
: "border-white/10 bg-black/20 hover:border-white/20",
step.disabled && "cursor-not-allowed opacity-45 hover:border-white/10"
)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[14px] border",
isActive
? "border-emerald-300/60 bg-emerald-400/20 text-emerald-200"
: step.completed
? "border-emerald-400/35 bg-emerald-500/10 text-emerald-300"
: "border-white/12 bg-white/[0.03] text-white/55"
)}
>
<Icon className="h-[18px] w-[18px]" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium leading-none text-white">
{step.label}
</span>
{step.optional ? (
<span className="rounded-full bg-white/8 px-1.5 py-0.5 text-[9px] font-normal leading-none text-white/45">
可选
</span>
) : null}
</div>
</div>
</button>
{index < toolSteps.length - 1 ? (
<div
className={cn(
"h-px w-4 shrink-0 rounded-full lg:w-5",
step.completed ? "bg-emerald-400/45" : "bg-white/10"
)}
/>
) : null}
</Fragment>
);
})}
</div>
</div>
</motion.section>
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@ -503,27 +503,27 @@ export function AgentPresetDialog({
<ScrollArea className="max-h-[72vh] pr-3">
<div className="space-y-4">
<div className={cn("grid grid-cols-1 gap-3", isDetailReplacementModeBuilder ? "md:grid-cols-1" : "md:grid-cols-2")}>
<div className={cn("grid grid-cols-1 gap-5", isDetailReplacementModeBuilder ? "md:grid-cols-1" : "md:grid-cols-2")}>
{!isDetailReplacementModeBuilder ? (
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>预览图</div>
<div className="flex items-start gap-4">
<div className="space-y-2.5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>预览图</div>
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${entityLabel}预览图`}
className={cn(
"h-24 w-24 rounded-2xl border object-cover shadow-sm",
isDark ? "border-white/10" : ""
"h-20 w-20 rounded-xl border object-cover shadow-sm",
isDark ? "border-white/10" : "border-border"
)}
/>
) : (
<div
className={cn(
"flex h-24 w-24 items-center justify-center rounded-2xl border text-sm shadow-sm",
"flex h-20 w-20 items-center justify-center rounded-xl border text-xs shadow-sm",
isDark
? "border-white/10 bg-black/30 text-white/35"
: "bg-muted text-muted-foreground"
? "border-white/10 bg-black/30 text-white/40"
: "bg-muted text-muted-foreground border-border"
)}
>
未上传
@ -544,7 +544,7 @@ export function AgentPresetDialog({
variant="outline"
size="sm"
className={cn(
"h-11 px-5 text-base",
"h-9 px-4 text-sm gap-1.5",
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
@ -553,9 +553,9 @@ export function AgentPresetDialog({
disabled={avatarUploading}
>
{avatarUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
<Upload className="h-3.5 w-3.5" />
)}
上传预览图
</Button>
@ -563,31 +563,33 @@ export function AgentPresetDialog({
</div>
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
<div className="space-y-2.5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
{entityNameLabel}
</div>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
className={cn(
"h-10",
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
? "border-white/10 bg-black/30 text-white placeholder:text-white/25 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
</div>
<div className={cn("grid grid-cols-1 gap-3", type === "image" ? "md:grid-cols-4" : "md:grid-cols-3")}>
<div className={cn("grid grid-cols-1 gap-4", type === "image" ? "md:grid-cols-4" : "md:grid-cols-3")}>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>可见性</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>可见性</div>
<Select
value={visibility}
onValueChange={(value: "public" | "private") => setVisibility(value)}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
@ -603,7 +605,7 @@ export function AgentPresetDialog({
</div>
{type === "image" ? (
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模式类型</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>模式类型</div>
<Select
value={modeConfig.modeType}
onValueChange={(value: AgentPresetModeType) =>
@ -615,6 +617,7 @@ export function AgentPresetDialog({
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
@ -637,7 +640,7 @@ export function AgentPresetDialog({
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>类型</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>类型</div>
<Select
value={type}
onValueChange={(value: AgentPresetType) => {
@ -647,6 +650,7 @@ export function AgentPresetDialog({
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
@ -662,10 +666,11 @@ export function AgentPresetDialog({
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>对应模型</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>对应模型</div>
<Select value={defaultModelId} onValueChange={setDefaultModelId}>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
@ -691,14 +696,14 @@ export function AgentPresetDialog({
{type === "image" ? (
<div
className={cn(
"rounded-2xl border p-3",
isDark ? "border-white/10 bg-black/20" : "border-slate-200 bg-muted/20"
"rounded-xl border px-4 py-3.5",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
当前模式:{modeConfig.modeType === "legacy" ? "旧版兼容" : selectedImageModeMeta.label}
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
{modeConfig.modeType === "legacy"
? "当前模板沿用旧版多槽位执行逻辑。切换到新模式后,将按新的业务流程配置。"
: selectedImageModeMeta.description}
@ -709,15 +714,15 @@ export function AgentPresetDialog({
{type === "image" && modeConfig.modeType !== "legacy" ? (
<div
className={cn(
"rounded-2xl border p-4",
isDark ? "border-white/10 bg-black/20" : "border-slate-200 bg-muted/20"
"rounded-xl border p-5",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className="mb-4">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
<div className="mb-5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
{selectedImageModeMeta.label}配置
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
{isSceneModeBuilder
? "第一步提示词组合 + 自定义提示词 + 输入图片 + 模板参考图,共同组成生图输入。"
: isDetailReplacementModeBuilder
@ -727,9 +732,9 @@ export function AgentPresetDialog({
</div>
{isSceneModeBuilder ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>产品图槽位数量</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>产品图槽位数量</div>
<Select
value={String(modeConfig.scene.productImageSlotCount)}
onValueChange={(value) =>
@ -745,6 +750,7 @@ export function AgentPresetDialog({
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
@ -766,11 +772,14 @@ export function AgentPresetDialog({
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模特图槽位</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
<div className="flex flex-col gap-3">
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>模特图槽位</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
开启后会额外保留 1 个模特图槽位
</div>
</div>
@ -786,13 +795,17 @@ export function AgentPresetDialog({
},
}))
}
className="shrink-0"
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>允许模板参考图</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>允许模板参考图</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
不强制,但可以预置风格参考图
</div>
</div>
@ -808,13 +821,17 @@ export function AgentPresetDialog({
},
}))
}
className="shrink-0"
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>允许用户切换模型</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>允许用户切换模型</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
关闭后第二步直接锁定模板模型
</div>
</div>
@ -830,6 +847,7 @@ export function AgentPresetDialog({
},
}))
}
className="shrink-0"
/>
</div>
</div>
@ -1247,11 +1265,12 @@ export function AgentPresetDialog({
</div>
</ScrollArea>
<DialogFooter>
<DialogFooter className="gap-2 pt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className={cn(
"h-9 px-5 text-sm",
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
@ -1263,12 +1282,13 @@ export function AgentPresetDialog({
onClick={handleCreate}
disabled={isSubmitting}
className={cn(
"h-9 px-5 text-sm gap-1.5",
isDark
? "bg-gradient-to-r from-emerald-500 to-teal-500 text-white hover:from-emerald-400 hover:to-teal-400"
: "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
)}
>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isSubmitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{submitLabel}
</Button>
</DialogFooter>

View File

@ -8,7 +8,11 @@ import { Button } from "@/components/ui/button";
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,
flattenAgentWorkflowSteps,
getEnabledAgentWorkflowStepTree,
} from "@/lib/agents/workflow-ui-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import {
@ -52,7 +56,7 @@ function buildSelectionSummary(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const step of params.config.steps) {
for (const { step } of flattenAgentWorkflowSteps(params.config.steps)) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
@ -183,18 +187,16 @@ export function AgentWorkflowPanelDarkV2({
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
return {
steps: [...source.steps]
.filter((step) => step.enabled)
.map((step) => ({
...step,
options: [...step.options]
.filter((option) => option.enabled)
.sort((left, right) => left.sortOrder - right.sortOrder),
}))
.filter((step) => step.options.length > 0)
.sort((left, right) => left.sortOrder - right.sortOrder),
steps: getEnabledAgentWorkflowStepTree(source.steps || []),
};
}, [config]);
const flattenedResolvedSteps = useMemo(
() =>
flattenAgentWorkflowSteps(resolvedConfig.steps).filter(
({ step }) => Array.isArray(step.options) && step.options.length > 0
),
[resolvedConfig.steps]
);
const summary = useMemo(
() => buildSelectionSummary({ config: resolvedConfig, selections }),
@ -300,10 +302,13 @@ export function AgentWorkflowPanelDarkV2({
</motion.section>
{/* 工作流步骤 */}
{resolvedConfig.steps.map((step, stepIndex) => (
{flattenedResolvedSteps.map(({ step, depth }, stepIndex) => (
<motion.section
key={step.key}
className="rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
className={cn(
"rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
depth > 1 && "ml-3 border-l-4 border-l-emerald-500/30 sm:ml-6"
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + stepIndex * 0.05 }}
@ -313,6 +318,11 @@ export function AgentWorkflowPanelDarkV2({
{stepIndex + 2}
</span>
{step.title}
{depth > 1 ? (
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[11px] font-medium text-white/45">
{depth === 2 ? "二级模块" : "三级模块"}
</span>
) : null}
</h2>
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>

View File

@ -1,4 +1,8 @@
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowUiConfig,
flattenAgentWorkflowSteps,
getEnabledAgentWorkflowStepTree,
} from "@/lib/agents/workflow-ui-config";
import type {
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
@ -23,12 +27,12 @@ export function buildAgentWorkflowComposerContext(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null {
const config = params.config || defaultAgentWorkflowUiConfig;
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 steps = flattenAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : [])
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps
.filter((step) => step.enabled !== false)
.map((step) => {
const selectedKey = params.selections[step.key];
if (!selectedKey) return null;
@ -219,10 +223,11 @@ export function getAgentWorkflowProgress(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const config = params.config || defaultAgentWorkflowUiConfig;
const enabledSteps = (config.steps || []).filter(
(step) =>
step.enabled !== false && Array.isArray(step.options) && step.options.some((item) => item.enabled !== false)
);
const enabledSteps = flattenAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(config.steps || [])
)
.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());

View File

@ -5,6 +5,13 @@ import type {
} from "@/types/agent";
export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 3;
export interface FlattenedAgentWorkflowStep {
step: AgentWorkflowStepUiConfig;
depth: number;
parentKeys: string[];
}
function cloneOption(
option: AgentWorkflowOptionUiConfig
@ -22,6 +29,7 @@ function cloneStep(step: AgentWorkflowStepUiConfig): AgentWorkflowStepUiConfig {
...step,
description: step.description || "",
options: step.options.map(cloneOption),
children: (step.children || []).map(cloneStep),
};
}
@ -38,6 +46,7 @@ function createStep(
enabled: true,
sortOrder: 0,
options,
children: [],
};
}
@ -129,6 +138,78 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
),
};
export function flattenAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
depth = 1,
parentKeys: string[] = []
): FlattenedAgentWorkflowStep[] {
return steps.flatMap((step) => [
{
step,
depth,
parentKeys,
},
...flattenAgentWorkflowSteps(step.children || [], depth + 1, [...parentKeys, step.key]),
]);
}
export function findAgentWorkflowStepByKey(
steps: AgentWorkflowStepUiConfig[],
stepKey: string
): AgentWorkflowStepUiConfig | null {
for (const step of steps) {
if (step.key === stepKey) {
return step;
}
const foundChild = findAgentWorkflowStepByKey(step.children || [], stepKey);
if (foundChild) {
return foundChild;
}
}
return null;
}
export function countAgentWorkflowSteps(steps: AgentWorkflowStepUiConfig[]): number {
return flattenAgentWorkflowSteps(steps).length;
}
export function countAgentWorkflowOptions(steps: AgentWorkflowStepUiConfig[]): number {
return flattenAgentWorkflowSteps(steps).reduce(
(total, item) => total + (item.step.options?.length || 0),
0
);
}
export function normalizeAgentWorkflowStepOrdering(
steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] {
return steps.map((step, stepIndex) => ({
...step,
sortOrder: stepIndex,
options: (step.options || []).map((option, optionIndex) => ({
...option,
sortOrder: optionIndex,
})),
children: normalizeAgentWorkflowStepOrdering(step.children || []),
}));
}
export function getEnabledAgentWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.filter((step) => step.enabled !== false)
.map((step) => ({
...step,
options: [...(step.options || [])]
.filter((option) => option.enabled !== false)
.sort((left, right) => left.sortOrder - right.sortOrder),
children: getEnabledAgentWorkflowStepTree(step.children || []),
}))
.filter((step) => step.options.length > 0 || (step.children || []).length > 0);
}
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
@ -178,97 +259,116 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
? (raw.inputOptions as Record<string, unknown>)
: {};
const defaultStepByKey = new Map(
defaultAgentWorkflowUiConfig.steps.map((step) => [step.key, step] as const)
flattenAgentWorkflowSteps(defaultAgentWorkflowUiConfig.steps).map(({ step }) => [
step.key,
step,
] as const)
);
const sourceSteps = hasRawSteps ? rawSteps : (defaultAgentWorkflowUiConfig.steps as unknown[]);
const usedStepKeys = new Set<string>();
const normalizeStep = (
item: unknown,
stepIndex: number,
depth: number
): AgentWorkflowStepUiConfig => {
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(
(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,
};
})
.sort((left, right) => left.sortOrder - right.sortOrder);
const hasRawChildren = Array.isArray(stepRecord?.children);
const rawChildren = hasRawChildren ? (stepRecord?.children as unknown[]) : [];
const defaultChildren =
!hasRawChildren && defaultStep?.children ? defaultStep.children : [];
const childSource =
depth < AGENT_WORKFLOW_MAX_TREE_DEPTH && (rawChildren.length > 0 || hasRawChildren)
? rawChildren
: depth < AGENT_WORKFLOW_MAX_TREE_DEPTH
? defaultChildren
: [];
return {
key: stepKey,
title: normalizeText(stepRecord?.title, 64) || fallbackTitle,
description: normalizeText(stepRecord?.description, 300) || fallbackDescription,
enabled: normalizeBoolean(stepRecord?.enabled, defaultStep?.enabled ?? true),
sortOrder: normalizeNumber(stepRecord?.sortOrder, defaultStep?.sortOrder ?? stepIndex),
options,
children:
depth < AGENT_WORKFLOW_MAX_TREE_DEPTH
? childSource
.map((child, childIndex) => normalizeStep(child, childIndex, depth + 1))
.sort((left, right) => left.sortOrder - right.sortOrder)
: [],
};
};
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: stepKey,
title: normalizeText(stepRecord?.title, 64) || fallbackTitle,
description:
normalizeText(stepRecord?.description, 300) || fallbackDescription,
enabled: normalizeBoolean(stepRecord?.enabled, defaultStep?.enabled ?? true),
sortOrder: normalizeNumber(
stepRecord?.sortOrder,
defaultStep?.sortOrder ?? stepIndex
),
options,
};
})
.map((item, stepIndex) => normalizeStep(item, stepIndex, 1))
.sort((left, right) => left.sortOrder - right.sortOrder);
return {
@ -276,6 +376,6 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
brands: normalizeStringList(rawInputOptions.brands),
stores: normalizeStringList(rawInputOptions.stores),
},
steps: normalizedSteps.sort((left, right) => left.sortOrder - right.sortOrder),
steps: normalizedSteps,
};
}

View File

@ -60,6 +60,7 @@ export interface AgentWorkflowStepUiConfig {
enabled: boolean;
sortOrder: number;
options: AgentWorkflowOptionUiConfig[];
children?: AgentWorkflowStepUiConfig[];
}
export interface AgentWorkflowUiConfig {