بلاک‌ها · پشتیبانی و محتوا

دستیارِ هوشمند: تاریخچهٔ روزبه‌روز در کنار، خوشامدِ شخصی با پیشنهادهای آماده، گفت‌وگو با فکرکردن، کنش‌های کپی و تلاشِ دوباره و بازخورد، و ورودی با پیوست و انتخابِ مدل.

۱. نصب کامپوننت‌ها

این بلاک از ۹ آیتم دیگ استفاده می‌کند: 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  );
}