ai-chatbot/components/agents/AgentWorkflowPanel.tsx
Your Name fbbc63f865
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: chain workflow with agent presets
2026-03-17 18:04:30 +08:00

316 lines
11 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 { Copy, RotateCcw, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerSelection,
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;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
onSelectionsChange: (next: Partial<Record<AgentWorkflowStepKey, string>>) => void;
onReset?: () => void;
embedded?: boolean;
}
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,
selections,
onSelectionsChange,
onReset,
embedded = false,
}: AgentWorkflowPanelProps) {
const resolvedConfig = useMemo(() => {
const source = config?.steps?.length ? 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),
}))
.sort((left, right) => left.sortOrder - right.sortOrder),
};
}, [config]);
const summary = useMemo(
() => buildSelectionSummary({ config: resolvedConfig, selections }),
[resolvedConfig, selections]
);
const composerContext = useMemo(
() =>
buildAgentWorkflowComposerContext({
config: resolvedConfig,
inputs,
selections,
}),
[inputs, 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;
}
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">
<Input
value={inputs.primarySku}
onChange={(event) =>
onInputsChange({ ...inputs, primarySku: event.target.value })
}
placeholder="主 SKU"
/>
<Input
value={inputs.secondarySku}
onChange={(event) =>
onInputsChange({ ...inputs, secondarySku: event.target.value })
}
placeholder="搭配 SKU"
/>
<Input
value={inputs.brand}
onChange={(event) =>
onInputsChange({ ...inputs, brand: event.target.value })
}
placeholder="品牌"
/>
<Input
value={inputs.store}
onChange={(event) =>
onInputsChange({ ...inputs, store: event.target.value })
}
placeholder="店铺"
/>
</div>
</section>
{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="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 ? (
<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}
</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>
)}
</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"
/>
</section>
</div>
</ScrollArea>
</div>
);
}