ai-chatbot/components/agents/AgentWorkflowPanelDark.tsx
Your Name f764767ba4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refresh agent workflow dark ui
2026-03-18 00:24:32 +08:00

441 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 { motion } from "framer-motion";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight } 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 } 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 AgentWorkflowPanelDarkProps {
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;
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;
}
function StepOptionCard({
option,
step,
selected,
onClick,
index,
}: {
option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null };
step: { title: string };
selected: boolean;
onClick: () => void;
index: number;
}) {
return (
<motion.button
type="button"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.05 }}
whileHover={{ y: -4, scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"group relative overflow-hidden rounded-2xl text-left",
"aspect-[16/10] w-full",
"transition-all duration-500",
selected
? "ring-2 ring-white/30 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
: "ring-1 ring-white/5 hover:ring-white/20"
)}
>
{/* 背景 */}
<div className="absolute inset-0 bg-zinc-900">
{option.previewImageUrl ? (
<>
<img
src={option.previewImageUrl}
alt={option.buttonName}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
</>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-800 to-zinc-900">
<Sparkles className="h-10 w-10 text-white/20" />
</div>
)}
</div>
{/* 选中标记 */}
{selected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black"
>
<Check className="h-4 w-4" />
</motion.div>
)}
{/* 步骤标签 */}
<div className="absolute left-3 top-3">
<span className="rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
{step.title}
</span>
</div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<h4 className="text-sm font-semibold text-white drop-shadow-lg">
{option.buttonName}
</h4>
{option.description && (
<p className="mt-1 line-clamp-1 text-xs text-white/60">
{option.description}
</p>
)}
</div>
{/* 选中边框 */}
{selected && (
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
)}
</motion.button>
);
}
export function AgentWorkflowPanelDark({
config,
inputs,
onInputsChange,
materials,
onMaterialsChange,
selections,
onSelectionsChange,
onReset,
onNextStep,
onSkipStep,
}: AgentWorkflowPanelDarkProps) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
);
const storeOptions = useMemo(
() => config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || [],
[config?.inputOptions?.stores]
);
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="min-h-screen bg-black">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-8">
<div className="flex h-20 items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-white">智能体工作流</h1>
<p className="text-xs text-white/40">配置工作流参数后进入生图模式</p>
</div>
</div>
{/* 进度指示 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-white/60">
<span>进度</span>
<span className="font-semibold text-white">{progress.completed}/{progress.total}</span>
</div>
<div className="h-2 w-32 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-violet-500 to-purple-500"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-[1800px] px-8 py-8">
<ScrollArea className="h-[calc(100vh-120px)]">
<div className="space-y-8 pb-20">
{/* 基础配置 */}
<section>
<motion.h2
className="mb-4 flex items-center gap-2 text-lg font-semibold text-white"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-500/20 text-xs font-bold text-violet-400">
1
</span>
基础配置
</motion.h2>
<motion.div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<SearchableSelect
value={inputs.brand}
options={brandOptions}
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
/>
<SearchableSelect
value={inputs.store}
options={storeOptions}
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</motion.div>
</section>
{/* 工作流步骤 */}
{resolvedConfig.steps.map((step, stepIndex) => (
<section key={step.key}>
<motion.h2
className="mb-4 flex items-center gap-2 text-lg font-semibold text-white"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 + stepIndex * 0.05 }}
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-500/20 text-xs font-bold text-violet-400">
{stepIndex + 2}
</span>
{step.title}
</motion.h2>
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{step.options.map((option, optionIndex) => (
<StepOptionCard
key={option.key}
option={option}
step={step}
selected={selections[step.key] === option.key}
onClick={() =>
onSelectionsChange({
...selections,
[step.key]: selections[step.key] === option.key ? undefined : option.key,
})
}
index={optionIndex}
/>
))}
</div>
</section>
))}
{/* 当前选择摘要 */}
{summary.length > 0 && (
<motion.section
className="rounded-2xl border border-white/5 bg-white/[0.02] p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<h3 className="mb-4 text-sm font-semibold text-white/80">当前选择</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={`${item.stepKey}-${item.optionKey}`}
className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
>
<span className="text-sm text-white/50">{item.stepTitle}</span>
<span className="text-sm font-medium text-white">{item.optionName}</span>
</div>
))}
</div>
</motion.section>
)}
{/* Prompt 预览 */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white/80">Prompt 预览</h3>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPrompt}
className="h-8 gap-1.5 text-xs text-white/60 hover:bg-white/10 hover:text-white"
>
<Copy className="h-3.5 w-3.5" />
复制
</Button>
</div>
<Textarea
value={composerContext?.promptText || ""}
readOnly
rows={6}
placeholder="选择工作流后,这里会自动生成结构化 Prompt"
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
/>
</section>
{/* 底部操作 */}
{onNextStep && (
<motion.div
className="flex justify-end gap-3 pt-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{onSkipStep && (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
跳过
<ChevronRight className="h-4 w-4" />
</Button>
)}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-white text-black hover:bg-white/90",
"font-medium transition-all",
"disabled:opacity-30"
)}
>
下一步:生图模式
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)}
</div>
</ScrollArea>
</main>
</div>
);
}