386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import {
|
|||
|
|
BarChart3,
|
|||
|
|
Check,
|
|||
|
|
ChevronDown,
|
|||
|
|
FileText,
|
|||
|
|
GitBranch,
|
|||
|
|
Loader2,
|
|||
|
|
Megaphone,
|
|||
|
|
Sparkles,
|
|||
|
|
Target,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|||
|
|
import { useState } from "react";
|
|||
|
|
import {
|
|||
|
|
AGENT_AUDIENCE_TAGS,
|
|||
|
|
AGENT_PROGRESS_STAGES,
|
|||
|
|
} from "./playground-content";
|
|||
|
|
import {
|
|||
|
|
agentEase,
|
|||
|
|
agentPanelTransition,
|
|||
|
|
agentSoftSpring,
|
|||
|
|
agentStaggerFast,
|
|||
|
|
agentThreadItem,
|
|||
|
|
} from "./agent-motion";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
import { type } from "@/lib/typography";
|
|||
|
|
import type { ChatMessage } from "./types";
|
|||
|
|
|
|||
|
|
interface AgentThreadProps {
|
|||
|
|
messages: ChatMessage[];
|
|||
|
|
isTyping?: boolean;
|
|||
|
|
bottomRef?: React.RefObject<HTMLDivElement | null>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ProgressCard() {
|
|||
|
|
const activeStage = 2;
|
|||
|
|
const reduceMotion = useReducedMotion();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<motion.div
|
|||
|
|
className="rounded-2xl border bg-card p-4 shadow-sm"
|
|||
|
|
variants={agentThreadItem}
|
|||
|
|
initial="hidden"
|
|||
|
|
animate="visible"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<h3 className={type["card-title"]}>تولید سناریو آغاز شد</h3>
|
|||
|
|
<span className={cn(type.caption, "text-muted-foreground")}>۵ دقیقه</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-4 flex gap-1">
|
|||
|
|
{AGENT_PROGRESS_STAGES.map((stage, index) => {
|
|||
|
|
const isComplete = index < activeStage;
|
|||
|
|
const isActive = index === activeStage;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={stage} className="flex-1 space-y-1.5">
|
|||
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
|||
|
|
<motion.div
|
|||
|
|
className="h-full rounded-full bg-primary"
|
|||
|
|
initial={{ scaleX: 0 }}
|
|||
|
|
animate={{
|
|||
|
|
scaleX: isComplete || isActive ? 1 : 0,
|
|||
|
|
opacity: isComplete || isActive ? 1 : 0,
|
|||
|
|
}}
|
|||
|
|
style={{ transformOrigin: "right center" }}
|
|||
|
|
transition={{
|
|||
|
|
duration: reduceMotion ? 0 : 0.55,
|
|||
|
|
delay: reduceMotion ? 0 : index * 0.1,
|
|||
|
|
ease: agentEase,
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<motion.p
|
|||
|
|
className={cn(
|
|||
|
|
type.caption,
|
|||
|
|
"text-center text-[10px] uppercase tracking-wide",
|
|||
|
|
isComplete || isActive
|
|||
|
|
? "text-primary"
|
|||
|
|
: "text-muted-foreground"
|
|||
|
|
)}
|
|||
|
|
initial={{ opacity: 0 }}
|
|||
|
|
animate={{ opacity: 1 }}
|
|||
|
|
transition={{ delay: reduceMotion ? 0 : 0.15 + index * 0.06 }}
|
|||
|
|
>
|
|||
|
|
{stage}
|
|||
|
|
</motion.p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</motion.div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TaskRow({
|
|||
|
|
title,
|
|||
|
|
status,
|
|||
|
|
expanded,
|
|||
|
|
onToggle,
|
|||
|
|
children,
|
|||
|
|
index = 0,
|
|||
|
|
}: {
|
|||
|
|
title: string;
|
|||
|
|
status: "done" | "active" | "pending";
|
|||
|
|
expanded?: boolean;
|
|||
|
|
onToggle?: () => void;
|
|||
|
|
children?: React.ReactNode;
|
|||
|
|
index?: number;
|
|||
|
|
}) {
|
|||
|
|
const reduceMotion = useReducedMotion();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<motion.div
|
|||
|
|
className="overflow-hidden rounded-xl border bg-card"
|
|||
|
|
variants={agentThreadItem}
|
|||
|
|
initial="hidden"
|
|||
|
|
animate="visible"
|
|||
|
|
transition={{ ...agentPanelTransition, delay: reduceMotion ? 0 : 0.08 + index * 0.07 }}
|
|||
|
|
layout
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={onToggle}
|
|||
|
|
className="flex w-full items-center gap-3 px-4 py-3 text-start"
|
|||
|
|
>
|
|||
|
|
<motion.span
|
|||
|
|
className={cn(
|
|||
|
|
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
|||
|
|
status === "done" && "bg-primary/15 text-primary",
|
|||
|
|
status === "active" && "bg-primary text-primary-foreground",
|
|||
|
|
status === "pending" && "border text-muted-foreground"
|
|||
|
|
)}
|
|||
|
|
initial={{ scale: 0.6, opacity: 0 }}
|
|||
|
|
animate={{ scale: 1, opacity: 1 }}
|
|||
|
|
transition={agentSoftSpring}
|
|||
|
|
>
|
|||
|
|
{status === "done" ? (
|
|||
|
|
<Check className="size-3" aria-hidden />
|
|||
|
|
) : status === "active" ? (
|
|||
|
|
<Loader2 className="size-3 animate-spin" aria-hidden />
|
|||
|
|
) : (
|
|||
|
|
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
|||
|
|
)}
|
|||
|
|
</motion.span>
|
|||
|
|
<span className={cn("flex-1", type["body-sm"], status === "pending" && "text-muted-foreground")}>
|
|||
|
|
{title}
|
|||
|
|
</span>
|
|||
|
|
{onToggle && (
|
|||
|
|
<motion.span
|
|||
|
|
animate={{ rotate: expanded ? 180 : 0 }}
|
|||
|
|
transition={{ duration: 0.28, ease: agentEase }}
|
|||
|
|
>
|
|||
|
|
<ChevronDown
|
|||
|
|
className="size-4 shrink-0 text-muted-foreground"
|
|||
|
|
aria-hidden
|
|||
|
|
/>
|
|||
|
|
</motion.span>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
<AnimatePresence initial={false}>
|
|||
|
|
{expanded && children && (
|
|||
|
|
<motion.div
|
|||
|
|
key="content"
|
|||
|
|
initial={{ height: 0, opacity: 0 }}
|
|||
|
|
animate={{ height: "auto", opacity: 1 }}
|
|||
|
|
exit={{ height: 0, opacity: 0 }}
|
|||
|
|
transition={{ duration: reduceMotion ? 0 : 0.32, ease: agentEase }}
|
|||
|
|
className="overflow-hidden"
|
|||
|
|
>
|
|||
|
|
<div className="border-t px-4 pb-4 pt-3">{children}</div>
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
</motion.div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function AudienceTags() {
|
|||
|
|
const allTags = [
|
|||
|
|
...AGENT_AUDIENCE_TAGS.companyType,
|
|||
|
|
...AGENT_AUDIENCE_TAGS.stage,
|
|||
|
|
...AGENT_AUDIENCE_TAGS.region,
|
|||
|
|
];
|
|||
|
|
const reduceMotion = useReducedMotion();
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-wrap gap-1.5">
|
|||
|
|
{allTags.map((tag, index) => (
|
|||
|
|
<motion.span
|
|||
|
|
key={tag}
|
|||
|
|
className={cn(
|
|||
|
|
"rounded-md border bg-muted/50 px-2.5 py-1 text-foreground",
|
|||
|
|
type.caption
|
|||
|
|
)}
|
|||
|
|
initial={{ opacity: 0, scale: 0.85, y: 6 }}
|
|||
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|||
|
|
transition={{
|
|||
|
|
...agentSoftSpring,
|
|||
|
|
delay: reduceMotion ? 0 : index * 0.05,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{tag}
|
|||
|
|
</motion.span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function AssistantBubble({ content }: { content: string }) {
|
|||
|
|
return (
|
|||
|
|
<motion.div
|
|||
|
|
className="rounded-2xl border bg-card p-4 shadow-sm"
|
|||
|
|
initial={{ opacity: 0, y: 16 }}
|
|||
|
|
animate={{ opacity: 1, y: 0 }}
|
|||
|
|
transition={agentPanelTransition}
|
|||
|
|
>
|
|||
|
|
<p className={cn(type.chat, "whitespace-pre-wrap break-words text-card-foreground")}>
|
|||
|
|
{content}
|
|||
|
|
</p>
|
|||
|
|
</motion.div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function AgentThread({
|
|||
|
|
messages,
|
|||
|
|
isTyping = false,
|
|||
|
|
bottomRef,
|
|||
|
|
}: AgentThreadProps) {
|
|||
|
|
const [expandedTask, setExpandedTask] = useState(true);
|
|||
|
|
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
|||
|
|
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|||
|
|
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|||
|
|
<motion.div
|
|||
|
|
className="flex flex-col gap-4 p-4 sm:p-5"
|
|||
|
|
variants={agentStaggerFast}
|
|||
|
|
initial="hidden"
|
|||
|
|
animate="visible"
|
|||
|
|
>
|
|||
|
|
<AnimatePresence mode="popLayout">
|
|||
|
|
{lastUserMessage && (
|
|||
|
|
<motion.div
|
|||
|
|
key="user-prompt"
|
|||
|
|
className="rounded-2xl bg-muted/50 px-4 py-3.5"
|
|||
|
|
layout
|
|||
|
|
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
|||
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|||
|
|
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
|||
|
|
transition={agentPanelTransition}
|
|||
|
|
>
|
|||
|
|
<p className={cn(type["body-sm"], "whitespace-pre-wrap break-words text-foreground")}>
|
|||
|
|
{lastUserMessage.content}
|
|||
|
|
</p>
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
|
|||
|
|
<AnimatePresence mode="popLayout">
|
|||
|
|
{(isTyping || assistantMessages.length > 0) && (
|
|||
|
|
<motion.div
|
|||
|
|
key="agent-workflow"
|
|||
|
|
className="space-y-4"
|
|||
|
|
layout
|
|||
|
|
initial={{ opacity: 0 }}
|
|||
|
|
animate={{ opacity: 1 }}
|
|||
|
|
exit={{ opacity: 0 }}
|
|||
|
|
transition={{ duration: 0.35, ease: agentEase }}
|
|||
|
|
>
|
|||
|
|
<AnimatePresence>
|
|||
|
|
{isTyping && (
|
|||
|
|
<motion.div
|
|||
|
|
key="progress"
|
|||
|
|
initial={{ opacity: 0, height: 0 }}
|
|||
|
|
animate={{ opacity: 1, height: "auto" }}
|
|||
|
|
exit={{ opacity: 0, height: 0 }}
|
|||
|
|
transition={{ duration: 0.38, ease: agentEase }}
|
|||
|
|
>
|
|||
|
|
<ProgressCard />
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<TaskRow title="تحلیل نیازمندیها" status="done" index={0} />
|
|||
|
|
<TaskRow
|
|||
|
|
title="کشف حسابها و سیگنالها"
|
|||
|
|
status={isTyping ? "active" : "done"}
|
|||
|
|
expanded={expandedTask}
|
|||
|
|
onToggle={() => setExpandedTask((v) => !v)}
|
|||
|
|
index={1}
|
|||
|
|
>
|
|||
|
|
<AudienceTags />
|
|||
|
|
</TaskRow>
|
|||
|
|
<TaskRow
|
|||
|
|
title="ساخت پیامهای شخصیسازیشده"
|
|||
|
|
status={isTyping ? "pending" : "done"}
|
|||
|
|
index={2}
|
|||
|
|
/>
|
|||
|
|
<TaskRow
|
|||
|
|
title="تنظیم دنباله ارتباطی"
|
|||
|
|
status="pending"
|
|||
|
|
index={3}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<AnimatePresence>
|
|||
|
|
{!isTyping && lastAssistant && (
|
|||
|
|
<motion.div
|
|||
|
|
key="assistant"
|
|||
|
|
initial={{ opacity: 0, y: 14 }}
|
|||
|
|
animate={{ opacity: 1, y: 0 }}
|
|||
|
|
exit={{ opacity: 0, y: 8 }}
|
|||
|
|
transition={agentPanelTransition}
|
|||
|
|
>
|
|||
|
|
<AssistantBubble content={lastAssistant.content} />
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
|
|||
|
|
<AnimatePresence>
|
|||
|
|
{!isTyping && assistantMessages.length > 0 && (
|
|||
|
|
<motion.div
|
|||
|
|
key="ready-banner"
|
|||
|
|
className="flex items-center justify-between rounded-xl bg-muted/40 px-4 py-2.5"
|
|||
|
|
initial={{ opacity: 0, y: 10 }}
|
|||
|
|
animate={{ opacity: 1, y: 0 }}
|
|||
|
|
exit={{ opacity: 0 }}
|
|||
|
|
transition={{ ...agentPanelTransition, delay: 0.1 }}
|
|||
|
|
>
|
|||
|
|
<span className={cn(type["body-sm"], "text-muted-foreground")}>
|
|||
|
|
سناریوی شما آماده است!
|
|||
|
|
</span>
|
|||
|
|
<motion.button
|
|||
|
|
type="button"
|
|||
|
|
className={cn(
|
|||
|
|
"rounded-lg bg-foreground px-3 py-1.5 text-background",
|
|||
|
|
type.caption
|
|||
|
|
)}
|
|||
|
|
whileHover={{ scale: 1.03 }}
|
|||
|
|
whileTap={{ scale: 0.97 }}
|
|||
|
|
>
|
|||
|
|
پیشنمایش
|
|||
|
|
</motion.button>
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
</motion.div>
|
|||
|
|
)}
|
|||
|
|
</AnimatePresence>
|
|||
|
|
|
|||
|
|
<div ref={bottomRef} className="h-px shrink-0" aria-hidden />
|
|||
|
|
</motion.div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function FeatureCardIcon({
|
|||
|
|
icon,
|
|||
|
|
className,
|
|||
|
|
}: {
|
|||
|
|
icon: (typeof import("./playground-content").AGENT_FEATURE_CARDS)[number]["icon"];
|
|||
|
|
className?: string;
|
|||
|
|
}) {
|
|||
|
|
const iconClass = cn("size-4", className);
|
|||
|
|
|
|||
|
|
switch (icon) {
|
|||
|
|
case "megaphone":
|
|||
|
|
return <Megaphone className={iconClass} aria-hidden />;
|
|||
|
|
case "document":
|
|||
|
|
return <FileText className={iconClass} aria-hidden />;
|
|||
|
|
case "sparkles":
|
|||
|
|
return <Sparkles className={iconClass} aria-hidden />;
|
|||
|
|
case "chart":
|
|||
|
|
return <BarChart3 className={iconClass} aria-hidden />;
|
|||
|
|
case "target":
|
|||
|
|
return <Target className={iconClass} aria-hidden />;
|
|||
|
|
case "steps":
|
|||
|
|
return <GitBranch className={iconClass} aria-hidden />;
|
|||
|
|
}
|
|||
|
|
}
|