316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|