چت با دستیار
پیشنمایش تمامصفحهدستیارِ هوشمند: تاریخچهٔ روزبهروز در کنار، خوشامدِ شخصی با پیشنهادهای آماده، گفتوگو با فکرکردن، کنشهای کپی و تلاشِ دوباره و بازخورد، و ورودی با پیوست و انتخابِ مدل.
۱. نصب کامپوننتها
این بلاک از ۹ آیتم دیگ استفاده میکند: buttonchat-messagecontrol-sizeiconsprompt-inputscroll-areasheetthinkingtoast
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/chat-message.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/prompt-input.json https://docs.digdesign.ir/r/scroll-area.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/thinking.json https://docs.digdesign.ir/r/toast.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/chat-message.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/prompt-input.json https://docs.digdesign.ir/r/scroll-area.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/thinking.json https://docs.digdesign.ir/r/toast.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/chat-message.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/prompt-input.json https://docs.digdesign.ir/r/scroll-area.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/thinking.json https://docs.digdesign.ir/r/toast.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/chat-message.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/prompt-input.json https://docs.digdesign.ir/r/scroll-area.json https://docs.digdesign.ir/r/sheet.json https://docs.digdesign.ir/r/thinking.json https://docs.digdesign.ir/r/toast.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
ai-chat.tsx
"use client";
import * as React from "react";
import {
BarChartIcon,
BellIcon,
CopyIcon,
FileTextIcon,
MenuIcon,
PlusIcon,
RefreshIcon,
SearchIcon,
ThumbsDownIcon,
ThumbsUpIcon,
UsersIcon,
} from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ControlSizeProvider } from "@/components/ui/control-size";
import {
ChatMessage,
ChatMessageAction,
ChatMessageActions,
ChatMessageBubble,
ChatMessageContent,
} from "@/components/ui/chat-message";
import {
PromptInput,
PromptInputAttachButton,
PromptInputModelPicker,
PromptInputSubmitButton,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "@/components/ui/prompt-input";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Thinking, ThinkingTrigger } from "@/components/ui/thinking";
import { toast } from "@/components/ui/toast";
type Message = { id: number; role: "user" | "assistant"; text: string };
const SUGGESTIONS = [
{
icon: <UsersIcon />,
text: "مشتریانِ وفادارِ این ماه",
hint: "چه کسانی بیشتر از همه خریدهاند",
tone: "violet",
},
{
icon: <BarChartIcon />,
text: "گزارشِ فروشِ هفته",
hint: "مقایسه با هفتهٔ قبل، با نمودار",
tone: "blue",
},
{
icon: <BellIcon />,
text: "پیگیریهای امروز",
hint: "تماسها و پیامهایی که مانده",
tone: "amber",
},
{
icon: <FileTextIcon />,
text: "خلاصهٔ فاکتورهای معوق",
hint: "مبلغ و مشتری، به ترتیبِ تأخیر",
tone: "pink",
},
] as const;
const TONE: Record<(typeof SUGGESTIONS)[number]["tone"], string> = {
violet:
"bg-[color-mix(in_oklab,var(--dig-violet-500)_14%,var(--card))] text-[var(--dig-violet-600)] dark:text-[var(--dig-violet-300)]",
blue: "bg-[color-mix(in_oklab,var(--dig-blue-500)_14%,var(--card))] text-[var(--dig-blue-600)] dark:text-[var(--dig-blue-300)]",
amber:
"bg-[color-mix(in_oklab,var(--dig-amber-500)_16%,var(--card))] text-[var(--dig-amber-700)] dark:text-[var(--dig-amber-300)]",
pink: "bg-[color-mix(in_oklab,var(--dig-pink-500)_14%,var(--card))] text-[var(--dig-pink-600)] dark:text-[var(--dig-pink-300)]",
};
/** گویِ تزئینیِ دستیار: گرادیانِ شعاعیِ بنفش و آبی با هالهٔ نرم. */
function Orb({ size = "lg" }: { size?: "lg" | "sm" }) {
return (
<span
aria-hidden
className={`relative block shrink-0 rounded-full bg-[radial-gradient(circle_at_30%_25%,var(--dig-white)_0%,var(--dig-violet-300)_22%,var(--dig-violet-500)_55%,var(--dig-blue-600)_100%)] ${
size === "lg"
? "size-24 shadow-[0_20px_60px_-10px_color-mix(in_oklab,var(--dig-violet-500)_60%,transparent)] sm:size-28"
: "size-8 shadow-[0_4px_14px_-4px_color-mix(in_oklab,var(--dig-violet-500)_70%,transparent)]"
}`}
>
<span className="absolute inset-[18%] rounded-full bg-[radial-gradient(circle_at_70%_75%,color-mix(in_oklab,var(--dig-pink-400)_70%,transparent),transparent_60%)]" />
</span>
);
}
const HISTORY = [
{ day: "امروز", items: ["گزارشِ فروشِ مهر", "متنِ ایمیلِ پیگیری"] },
{
day: "دیروز",
items: ["مقایسهٔ هزینههای تبلیغات", "فهرستِ مشتریانِ جدید"],
},
{ day: "هفتهٔ گذشته", items: ["بودجهٔ فصلِ بعد"] },
];
const MODELS = [
{ value: "fast", label: "سریع" },
{ value: "pro", label: "دقیق" },
];
/**
* دستیارِ هوشمند: تاریخچه در ستونِ کناری، و در ستونِ اصلی روی زمینهٔ محیطیِ گرادیانی اول
* خوشامد با گوی و کارتهای پیشنهاد؛ با اولین پیام، گفتوگو جای آن را میگیرد. ورودی همیشه پایین
* است و پاسخِ آخر کنشهای کپی، تلاشِ دوباره و بازخورد را دارد.
*/
export default function AiChatBlock() {
const [messages, setMessages] = React.useState<Message[]>([]);
const [thinking, setThinking] = React.useState(false);
const [model, setModel] = React.useState("pro");
const [feedback, setFeedback] = React.useState<"up" | "down" | null>(null);
const endRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
endRef.current?.scrollIntoView({ block: "end" });
}, [messages, thinking]);
async function send(text: string) {
setFeedback(null);
setMessages((list) => [...list, { id: Date.now(), role: "user", text }]);
setThinking(true);
// جای پاسخِ مدل؛ در پروژهٔ واقعی این همان استریمِ پاسخ است.
await new Promise((resolve) => setTimeout(resolve, 1200));
setThinking(false);
setMessages((list) => [
...list,
{
id: Date.now() + 1,
role: "assistant",
text: "فروشِ این هفته ۱۸٪ بیشتر از هفتهٔ قبل بود. بیشترین رشد مالِ دستهٔ «لوازم خانه» است و سه مشتری بیش از نیمی از سفارشها را ثبت کردهاند.",
},
]);
}
const lastAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
const history = (
<>
<div className="flex flex-col gap-2">
<Button fullWidth onClick={() => setMessages([])}>
<PlusIcon />
گفتوگوی تازه
</Button>
<Button variant="ghost" fullWidth className="justify-start">
<SearchIcon />
جستوجو در گفتوگوها
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<nav className="flex flex-col gap-6">
{HISTORY.map((group) => (
<div key={group.day} className="flex flex-col gap-1">
<span className="px-3 text-xs font-medium text-muted-foreground">
{group.day}
</span>
{group.items.map((item) => (
<Button
key={item}
variant="ghost"
fullWidth
className="justify-start font-normal"
>
<span className="truncate">{item}</span>
</Button>
))}
</div>
))}
</nav>
</ScrollArea>
</>
);
return (
<ControlSizeProvider size="xl" asChild>
<div className="flex h-svh bg-background">
{/* تاریخچه: گفتوگوی تازه و جستوجو بالا، فهرستِ روزبهروز زیرش. در موبایل همین در کشوی کناری. */}
<aside
aria-label="تاریخچهٔ گفتوگوها"
className="hidden w-72 shrink-0 flex-col gap-6 border-e border-border bg-muted p-4 md:flex"
>
{history}
</aside>
<main className="relative isolate flex min-w-0 flex-1 flex-col">
{/* زمینهٔ محیطی: دو هالهٔ رنگیِ ملایم که در دارک هم با پسزمینه ترکیب میشوند. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(60%_45%_at_80%_0%,color-mix(in_oklab,var(--dig-violet-400)_22%,transparent),transparent_70%),radial-gradient(50%_40%_at_10%_100%,color-mix(in_oklab,var(--dig-blue-400)_18%,transparent),transparent_70%)]"
/>
<header className="flex items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex items-center gap-2">
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="تاریخچهٔ گفتوگوها"
className="-ms-2 md:hidden"
>
<MenuIcon />
</Button>
</SheetTrigger>
<ControlSizeProvider size="xl" asChild>
<SheetContent
side="start"
className="flex w-80 flex-col gap-6 bg-muted p-4 pt-14"
>
<SheetTitle className="sr-only">
تاریخچهٔ گفتوگوها
</SheetTitle>
{history}
</SheetContent>
</ControlSizeProvider>
</Sheet>
<h1 className="font-semibold">دستیار هوشمند</h1>
</div>
<span className="text-sm text-muted-foreground">
مدل: {MODELS.find((item) => item.value === model)?.label}
</span>
</header>
<ScrollArea className="min-h-0 flex-1">
{messages.length === 0 ? (
// خوشامد: گوی، سلامِ شخصی و کارتهای پیشنهاد با توضیحِ کوتاه.
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col items-center justify-center gap-8 p-4 text-center sm:p-6">
<Orb />
<h2 className="text-2xl leading-snug font-black text-balance sm:text-4xl sm:leading-snug">
صبح بخیر، سارا!
<br />
<span className="bg-[linear-gradient(90deg,var(--dig-violet-600),var(--dig-blue-600))] bg-clip-text text-transparent dark:bg-[linear-gradient(90deg,var(--dig-violet-300),var(--dig-blue-300))]">
امروز چه میخواهی بدانی؟
</span>
</h2>
<ul className="grid w-full grid-cols-2 gap-3 sm:gap-4">
{SUGGESTIONS.map((item) => (
<li key={item.text}>
<Button
variant="outline"
onClick={() => send(item.text)}
className="h-full w-full flex-col items-start justify-start gap-3 rounded-xl bg-card p-4 text-start font-normal whitespace-normal shadow-card hover:border-[color-mix(in_oklab,var(--dig-violet-500)_40%,var(--border))] hover:bg-card"
>
<span
aria-hidden
className={`flex size-10 items-center justify-center rounded-lg [&>svg]:size-5 ${TONE[item.tone]}`}
>
{item.icon}
</span>
<span className="flex flex-col gap-1">
<span className="text-sm font-bold sm:text-base">
{item.text}
</span>
<span className="hidden text-xs leading-5 text-muted-foreground sm:block">
{item.hint}
</span>
</span>
</Button>
</li>
))}
</ul>
</div>
) : (
<div
className="mx-auto flex min-h-full w-full max-w-3xl flex-col justify-end gap-6 p-6"
aria-live="polite"
>
{messages.map((message) => (
<ChatMessage key={message.id} role={message.role}>
{message.role === "assistant" && <Orb size="sm" />}
<ChatMessageContent>
<ChatMessageBubble
className={
message.role === "user"
? "rounded-2xl rounded-se-md bg-[linear-gradient(135deg,var(--dig-violet-600),var(--dig-indigo-600))] text-[var(--dig-white)]"
: "rounded-2xl rounded-ss-md border border-border bg-card px-4 py-3 shadow-card"
}
>
{message.text}
</ChatMessageBubble>
{message === lastAssistant && !thinking && (
// کنشهای پاسخِ آخر همیشه دیده میشوند، نه فقط با هاور.
<ChatMessageActions reveal="always">
<ChatMessageAction
label="کپی"
onClick={() => {
void navigator.clipboard?.writeText(message.text);
toast.success("پاسخ کپی شد.");
}}
>
<CopyIcon />
</ChatMessageAction>
<ChatMessageAction
label="تلاشِ دوباره"
onClick={() => send("دوباره توضیح بده")}
>
<RefreshIcon />
</ChatMessageAction>
<ChatMessageAction
label="پاسخِ خوب"
aria-pressed={feedback === "up"}
onClick={() =>
setFeedback(feedback === "up" ? null : "up")
}
>
<ThumbsUpIcon filled={feedback === "up"} />
</ChatMessageAction>
<ChatMessageAction
label="پاسخِ بد"
aria-pressed={feedback === "down"}
onClick={() =>
setFeedback(feedback === "down" ? null : "down")
}
>
<ThumbsDownIcon filled={feedback === "down"} />
</ChatMessageAction>
</ChatMessageActions>
)}
</ChatMessageContent>
</ChatMessage>
))}
{thinking && (
<Thinking state="thinking">
<ThinkingTrigger />
</Thinking>
)}
<div ref={endRef} />
</div>
)}
</ScrollArea>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 p-4 sm:p-6">
<PromptInput
onSubmit={send}
disabled={thinking}
className="bg-card shadow-card"
>
<PromptInputTextarea
placeholder="هر چه میخواهی بپرس…"
aria-label="پیام"
/>
<PromptInputToolbar>
<PromptInputTools>
<PromptInputAttachButton />
<PromptInputModelPicker
models={MODELS}
value={model}
onValueChange={setModel}
/>
</PromptInputTools>
<PromptInputSubmitButton
loading={thinking}
className="bg-[linear-gradient(135deg,var(--dig-violet-600),var(--dig-indigo-600))] text-[var(--dig-white)]"
/>
</PromptInputToolbar>
</PromptInput>
<p className="text-center text-xs text-muted-foreground">
دستیار ممکن است اشتباه کند؛ عددهای مهم را دوباره بررسی کنید.
</p>
</div>
</main>
</div>
</ControlSizeProvider>
);
}