ai-chatbot/components/agents/AgentWorkflowPanel.tsx
Your Name 8d2066a3bf
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: expand agent workflow and project management
2026-03-19 18:33:42 +08:00

411 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo } from "react";
import { ArrowRight, Copy, RotateCcw, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
defaultAgentWorkflowUiConfig,
getAgentWorkflowBrandStoreEntries,
getAgentWorkflowStoreOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
export interface AgentWorkflowInputs {
primarySku: string;
secondarySku: string;
brand: string;
store: string;
}
interface AgentWorkflowPanelProps {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
onReset?: () => void;
embedded?: boolean;
onNextStep?: () => void;
onSkipStep?: () => void;
}
function buildSelectionSummary(params: {
config: AgentWorkflowUiConfig;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const step of params.config.steps) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
if (!option) continue;
result.push({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || "",
});
}
return result;
}
export function AgentWorkflowPanel({
config,
inputs,
onInputsChange,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onReset,
embedded = false,
onNextStep,
onSkipStep,
}: AgentWorkflowPanelProps) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const brandStoreEntries = useMemo(
() => getAgentWorkflowBrandStoreEntries(config),
[config]
);
const storeOptions = useMemo(
() => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }),
[config, inputs.brand]
);
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),
};
}, [config]);
const summary = useMemo(
() => buildSelectionSummary({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const composerContext = useMemo(
() =>
buildAgentWorkflowComposerContext({
config: resolvedConfig,
inputs,
materials,
selections,
}),
[inputs, materials, resolvedConfig, selections]
);
const progress = useMemo(
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const handleCopyPrompt = async () => {
if (!composerContext?.promptText) {
toast.error("当前还没有可复制的 Prompt");
return;
}
try {
await navigator.clipboard.writeText(composerContext.promptText);
toast.success("Prompt 已复制");
} catch {
toast.error("复制失败,请手动复制");
}
};
return (
<div
className={cn(
"flex h-full flex-col bg-white dark:bg-zinc-950",
embedded ? "" : "border-r"
)}
>
<div className="border-b p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Sparkles className="h-4 w-4 text-amber-500" />
智能体工作流
</div>
<div className="mt-1 text-xs text-muted-foreground">
先完成工作流设定,下一步再进入生图模式执行。
</div>
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
已完成 {progress.completed}/{progress.total} 个环节
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
>
<RotateCcw className="mr-1 h-4 w-4" />
清空
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-5 p-4">
<section className="space-y-3">
<div className="text-sm font-medium">基础配置</div>
<div className="grid grid-cols-1 gap-3">
<SearchableSelect
value={inputs.brand}
options={brandOptions}
placeholder="品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选,请先到系统设置配置"
onValueChange={(value) =>
onInputsChange({
...inputs,
brand: value,
store:
value.trim().toLowerCase() === inputs.brand.trim().toLowerCase()
? inputs.store
: "",
})
}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="店铺"
searchPlaceholder="输入店铺名筛选"
emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"}
disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)}
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>
{(brandOptions.length > 0 || storeOptions.length > 0) && (
<div className="text-xs text-muted-foreground">
品牌和店铺支持输入关键字匹配下拉选择,候选列表由系统设置配置。
</div>
)}
<div className="text-xs text-muted-foreground">
SKU / SPU 搜图已移动到下一步“生图模式”中,选中的图片会直接进入对应生图槽位。
</div>
</section>
{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>
{step.description ? (
<div className="mt-1 text-xs text-muted-foreground">{step.description}</div>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{step.options.map((option) => {
const selected = selections[step.key] === option.key;
return (
<button
key={option.key}
type="button"
onClick={() =>
onSelectionsChange({
...selections,
[step.key]: selected ? undefined : option.key,
})
}
className={cn(
"overflow-hidden rounded-2xl border text-left transition-all",
selected
? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(59,130,246,0.12)]"
: "border-border bg-background hover:border-primary/40 hover:shadow-sm"
)}
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-muted/40">
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground">
暂无预览图
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-medium text-black shadow-sm">
{selected ? "已选择" : step.title}
</div>
</div>
<div className="space-y-1.5 p-3">
<div className="text-sm font-semibold">{option.buttonName}</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{option.description || "点击选择该工作流方案"}
</div>
</div>
</button>
);
})}
</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>
{inputs.primarySku ||
inputs.secondarySku ||
inputs.brand ||
inputs.store ||
materials.primary.selectedImageIds.length > 0 ||
materials.secondary.selectedImageIds.length > 0 ? (
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{inputs.primarySku ? (
<span className="rounded-full bg-background px-2 py-1">主SKU:{inputs.primarySku}</span>
) : null}
{inputs.secondarySku ? (
<span className="rounded-full bg-background px-2 py-1">搭配SKU:{inputs.secondarySku}</span>
) : null}
{inputs.brand ? (
<span className="rounded-full bg-background px-2 py-1">品牌:{inputs.brand}</span>
) : null}
{inputs.store ? (
<span className="rounded-full bg-background px-2 py-1">店铺:{inputs.store}</span>
) : null}
{materials.primary.selectedImageIds.length > 0 ? (
<span className="rounded-full bg-background px-2 py-1">
主SPU图:已选 {materials.primary.selectedImageIds.length} 张
</span>
) : null}
{materials.secondary.selectedImageIds.length > 0 ? (
<span className="rounded-full bg-background px-2 py-1">
搭配SKU图:已选 {materials.secondary.selectedImageIds.length} 张
</span>
) : null}
</div>
) : null}
{summary.length > 0 ? (
<div className="space-y-2">
{summary.map((item) => (
<div
key={`${item.stepKey}-${item.optionKey}`}
className="flex items-center justify-between rounded-xl bg-background px-3 py-2 text-sm"
>
<span className="text-muted-foreground">{item.stepTitle}</span>
<span className="font-medium">{item.optionName}</span>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
还没有选择工作流方案,先从上面的卡片开始。
</div>
)}
{composerContext?.referenceImages.length ? (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">已选素材图</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{composerContext.referenceImages.map((image) => (
<span key={image.id} className="rounded-full bg-background px-2 py-1">
{image.sourceLabel} · {image.label}
</span>
))}
</div>
</div>
) : null}
</section>
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Prompt 预览</div>
<div className="mt-1 text-xs text-muted-foreground">
右侧发送时,会把下面这份工作流设定作为隐藏上下文一起注入。
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleCopyPrompt}>
<Copy className="mr-1 h-4 w-4" />
复制
</Button>
</div>
<Textarea
value={composerContext?.promptText || ""}
readOnly
rows={10}
placeholder="选择左侧工作流后,这里会自动生成结构化 Prompt。"
className="resize-none bg-background"
/>
{onNextStep ? (
<div className="flex flex-wrap justify-end gap-2">
{onSkipStep ? (
<Button type="button" variant="outline" onClick={onSkipStep}>
跳过选择,直接进入下一步
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
<Button
type="button"
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
>
下一步:选择生图模式
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
) : null}
</section>
</div>
</ScrollArea>
</div>
);
}