Files
aragrp/components/playground/agent-thread.tsx
T

386 lines
12 KiB
TypeScript
Raw Normal View History

"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 />;
}
}