- Accordion
- Alert
- Alert Dialog
- Autocomplete
- Auth Surface
- Avatar
- Badge
- Browse Catalog Dialog
- Button
- Card
- Checkbox
- Checkbox Group
- Collapsible
- Combobox
- Command
- Connector Setup Dialog
- Cookie Banner
- Dialog
- Directory Card
- Directory Detail
- Directory Skeleton
- DrawerНовое
- Token Parts Input
- Empty
- Field
- Fieldset
- File Preview Modal
- File Preview Skeleton
- Form
- Frame
- Group
- Icon
- Input
- Input Group
- Kbd
- Label
- Legal Shell
- Menu
- Mermaid Diagram
- Mind Map Diagram
- Not Found Screen
- Onboarding Frame
- Popover
- PDF Thumbnail
- Personalization Landing
- Preview Card
- Pricing Page
- Progress
- Radio Group
- Ring Spinner
- Scroll Area
- Select
- Separator
- Settings Page
- Settings Skills
- Settings Connectors
- Settings Capabilities
- Settings Usage
- Settings Account
- Settings Billing
- Sheet
- Sidebar
- Skeleton
- Skill Create Dialog
- Slider
- Spinner
- Stat
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Tooltip
- Компоненты AI
- Chat Conversation
- Chat Message
- Chat Response
- Chat Suggestion
- Chat Prompt Input
- Slash Highlighted Textarea
- Chat Search Dialog
- Chat Skill Doc
- Chat Connector Detail
- Chat Attachments
- Chat File Card
- Chat Token Chip
- Chat Code Block
- Chat Image
- Chat Inline Citation
- Chat Sources
- Chat Web Search
- Chat Research
- Chat Source
- Chat Actions
- Chat Context
- Chat Loader
- Chat Compaction
- Chat Timeline
- Chat Snippet
- Chat Terminal
- Chat Stack Trace
- Chat Test Results
- Chat File Tree
- Chat Environment Variables
- Chat Audio Player
- Chat Transcription
- Chat Speech Input
- Chat Mic Selector
- Chat Voice Selector
- Chat Agent
- Chat Persona
- Chat Connection
- Chat Connector Suggestion
- Chat Queue
- Chat Checkpoint
- Chat Confirmation
- Chat Artifact
- Chat JSX Preview
- Chat Schema Display
- Chat Package Info
- Chat Commit
- Chat Plan
- Chat Open In Chat
- Chat Sandbox
- Chat Model Selector
- Chat Canvas
- Chat Node
- Chat Edge
Chat Prompt Input
Композер чата — обёртка с авторастущим textarea, панелью инструментов, кластером инструментов, кнопкой отправки/остановки, триггером вложений, выбором модели и чипом исследования.
"use client";
import { IconMicrophone, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ChatPromptInput,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
export default function Particle() {
const [sent, setSent] = useState<string | null>(null);
return (
<div className="flex w-full max-w-xl flex-col gap-3 p-4">
<ChatPromptInput onMessageSend={setSent}>
<ChatPromptInputTextarea placeholder="Спроси что-нибудь…" />
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Button aria-label="Attach" size="icon-sm" variant="ghost">
<IconPaperclip aria-hidden="true" stroke={1.5} />
</Button>
<Button aria-label="Voice" size="icon-sm" variant="ghost">
<IconMicrophone aria-hidden="true" stroke={1.5} />
</Button>
</ChatPromptInputTools>
<ChatPromptInputSubmit status="ready" />
</ChatPromptInputToolbar>
</ChatPromptInput>
{sent ? (
<p className="text-muted-foreground text-sm">
Отправлено:{" "}
<span className="font-medium text-foreground">{sent}</span>
</p>
) : null}
</div>
);
}
Установка
pnpm dlx shadcn@latest add @oracul/chat-prompt-input
Использование
import {
ChatPromptInput,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
<ChatPromptInput onMessageSend={(text) => sendMessage(text)}>
<ChatPromptInputTextarea placeholder="Спроси что-нибудь…" />
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Button aria-label="Attach" size="icon-sm" variant="ghost">
<PaperclipIcon />
</Button>
</ChatPromptInputTools>
<ChatPromptInputSubmit status="ready" />
</ChatPromptInputToolbar>
</ChatPromptInput>Вложения внутри композера
Черновые вложения живут внутри скруглённой оболочки композера, над textarea —
1:1 с фронтендом. Оберните плитки в ChatPromptInputAttachments и поставьте его
первым дочерним элементом <ChatPromptInput>: форма сама задаёт p-3.5 оболочки и gap-3
до textarea, так что обёртка — это просто ряд flex flex-wrap gap-3. Плитки —
из @oracul/chat-attachments (крестик удаления -top-2 -left-2 появляется при наведении).
"use client";
import { IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ChatAttachment,
type ChatAttachmentItem,
} from "@/components/ui/chat-attachments";
import {
ChatPromptInput,
ChatPromptInputAttachments,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
const initial: ChatAttachmentItem[] = [
{
id: "1",
name: "hero.png",
kind: "image",
url: "https://picsum.photos/seed/oracul-composer/200/200",
thumbnail: "https://picsum.photos/seed/oracul-composer/200/200",
},
{ id: "2", name: "spec.pdf", kind: "doc", url: "blob:spec", ext: "PDF" },
{
id: "3",
name: "notes.md",
kind: "doc",
url: "blob:notes",
ext: "MD",
meta: "124 строки",
},
];
export default function Particle() {
const [items, setItems] = useState(initial);
const [sent, setSent] = useState<string | null>(null);
return (
<div className="flex w-full max-w-xl flex-col gap-3 p-4">
<ChatPromptInput onMessageSend={setSent}>
{items.length > 0 ? (
<ChatPromptInputAttachments>
{items.map((item) => (
<ChatAttachment
item={item}
key={item.id}
onRemove={(id) =>
setItems((prev) => prev.filter((a) => a.id !== id))
}
removeHint="Удалить"
/>
))}
</ChatPromptInputAttachments>
) : null}
<ChatPromptInputTextarea placeholder="Спроси что-нибудь…" />
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Button aria-label="Прикрепить" size="icon-sm" variant="ghost">
<IconPaperclip aria-hidden="true" stroke={1.5} />
</Button>
</ChatPromptInputTools>
<ChatPromptInputSubmit status="ready" />
</ChatPromptInputToolbar>
</ChatPromptInput>
{sent ? (
<p className="text-muted-foreground text-xs">Отправлено: {sent}</p>
) : null}
</div>
);
}
import {
ChatPromptInput,
ChatPromptInputAttachments,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import { ChatAttachment } from "@/components/ui/chat-attachments";
<ChatPromptInput onMessageSend={(text) => sendMessage(text)}>
{attachments.length > 0 ? (
<ChatPromptInputAttachments>
{attachments.map((item) => (
<ChatAttachment key={item.id} item={item} onRemove={removeAttachment} />
))}
</ChatPromptInputAttachments>
) : null}
<ChatPromptInputTextarea placeholder="Спроси что-нибудь…" />
<ChatPromptInputToolbar>{/* … */}</ChatPromptInputToolbar>
</ChatPromptInput>Полный композер с меню
ChatPromptInputMenuTrigger и ChatPromptInputModelTrigger — это универсальные оболочки-триггеры: содержимое меню (модели, навыки, коннекторы, переключатели) потребитель собирает сам через @oracul/menu. Триггеры только задают форму кнопок. Правый кластер (выбор модели + отправка) оборачивается в <div className="flex items-center gap-2"> — асимметричный отступ намеренный, отдельного примитива для него нет.
Введите сообщение и отправьте.
"use client";
import { IconPaperclip, IconSettings } from "@tabler/icons-react";
import { useState } from "react";
import {
ChatPromptInput,
ChatPromptInputMenuTrigger,
ChatPromptInputModelTrigger,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import {
Menu,
MenuItem,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuTrigger,
} from "@/components/ui/menu";
type Model = { value: string; name: string; suffix?: string };
const models: [Model, ...Model[]] = [
{ value: "opus", name: "Claude Opus", suffix: "расширенный" },
{ value: "sonnet", name: "Claude Sonnet", suffix: "максимум усилий" },
{ value: "haiku", name: "Claude Haiku" },
];
export default function Particle() {
const [value, setValue] = useState("");
const [model, setModel] = useState("opus");
const [isGenerating, setIsGenerating] = useState(false);
const canSend = value.trim().length > 0;
const [first] = models;
const selected = models.find((m) => m.value === model) ?? first;
const handleSend = () => {
setIsGenerating(true);
setValue("");
window.setTimeout(() => setIsGenerating(false), 1600);
};
return (
<div className="flex w-full max-w-xl flex-col gap-3 p-4">
<ChatPromptInput elevation="raised" onMessageSend={handleSend}>
<ChatPromptInputTextarea
onChange={(event) => setValue(event.target.value)}
placeholder="Спроси что-нибудь…"
value={value}
/>
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Menu>
<MenuTrigger
render={
<ChatPromptInputMenuTrigger aria-label="Добавить контент" />
}
/>
<MenuPopup align="start" className="min-w-56">
<MenuItem>
<IconPaperclip aria-hidden="true" stroke={1.5} />
Добавить файл или фото
</MenuItem>
<MenuItem>
<IconSettings aria-hidden="true" stroke={1.5} />
Управление навыками
</MenuItem>
</MenuPopup>
</Menu>
</ChatPromptInputTools>
<div className="flex items-center gap-2">
<Menu>
<MenuTrigger
render={
<ChatPromptInputModelTrigger
label={selected.name}
suffix={selected.suffix}
/>
}
/>
<MenuPopup align="end" side="top">
<MenuRadioGroup
onValueChange={(next) => setModel(String(next))}
value={model}
>
{models.map((m) => (
<MenuRadioItem key={m.value} value={m.value}>
{m.name}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuPopup>
</Menu>
<ChatPromptInputSubmit
disabled={!isGenerating && !canSend}
status={isGenerating ? "streaming" : "ready"}
/>
</div>
</ChatPromptInputToolbar>
</ChatPromptInput>
<p className="px-1 text-muted-foreground text-xs">
{isGenerating
? "Генерирую… нажмите «стоп», чтобы отменить."
: "Введите сообщение и отправьте."}
</p>
</div>
);
}
import {
ChatPromptInput,
ChatPromptInputMenuTrigger,
ChatPromptInputModelTrigger,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
<ChatPromptInput onMessageSend={send}>
<ChatPromptInputTextarea placeholder="Ask anything…" />
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Menu>
<MenuTrigger
render={<ChatPromptInputMenuTrigger aria-label="Add content" />}
/>
<MenuPopup align="start">
<MenuItem>Add file or photo</MenuItem>
</MenuPopup>
</Menu>
</ChatPromptInputTools>
<div className="flex items-center gap-2">
<Menu>
<MenuTrigger
render={
<ChatPromptInputModelTrigger
label="Claude Opus"
suffix="extended"
/>
}
/>
<MenuPopup align="end" side="top">
{/* MenuRadioGroup / MenuItem rows owned by the consumer */}
</MenuPopup>
</Menu>
<ChatPromptInputSubmit
disabled={!isGenerating && !canSend}
status={isGenerating ? "streaming" : "ready"}
/>
</div>
</ChatPromptInputToolbar>
</ChatPromptInput>Режим исследования
ChatPromptInputResearchChip показывается только когда режим активен. В покое — только иконка 28×28; при наведении расширяется до пилюли с лейблом и крестиком удаления. Активация чипа вызывает onRemove. Переключатели режимов собираются через MenuCheckboxItem variant="switch".
Режим исследования включён — наведите на чип, чтобы отключить.
"use client";
import {
IconAdjustmentsHorizontal,
IconTrendingUp,
IconWorld,
} from "@tabler/icons-react";
import { useState } from "react";
import {
ChatPromptInput,
ChatPromptInputMenuTrigger,
ChatPromptInputResearchChip,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import {
Menu,
MenuCheckboxItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
export default function Particle() {
const [value, setValue] = useState("");
const [research, setResearch] = useState(true);
const [webSearch, setWebSearch] = useState(false);
const canSend = value.trim().length > 0;
return (
<div className="flex w-full max-w-xl flex-col gap-3 p-4">
<ChatPromptInput elevation="flat" onMessageSend={() => setValue("")}>
<ChatPromptInputTextarea
onChange={(event) => setValue(event.target.value)}
placeholder="Исследуй что угодно…"
value={value}
/>
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Menu>
<MenuTrigger
render={
<ChatPromptInputMenuTrigger
aria-label="Инструменты и режимы"
icon={
<IconAdjustmentsHorizontal
aria-hidden="true"
stroke={1.5}
/>
}
/>
}
/>
<MenuPopup align="start" className="min-w-64">
<MenuCheckboxItem
checked={research}
onCheckedChange={setResearch}
variant="switch"
>
<span className="flex items-center gap-2.5">
<IconTrendingUp aria-hidden="true" stroke={1.5} />
Исследование
</span>
</MenuCheckboxItem>
<MenuCheckboxItem
checked={webSearch}
onCheckedChange={setWebSearch}
variant="switch"
>
<span className="flex items-center gap-2.5">
<IconWorld
aria-hidden="true"
className="text-info"
stroke={1.5}
/>
Поиск в интернете
</span>
</MenuCheckboxItem>
</MenuPopup>
</Menu>
{research ? (
<ChatPromptInputResearchChip
icon={<IconTrendingUp aria-hidden="true" stroke={1.5} />}
label="Исследование"
onRemove={() => setResearch(false)}
removeLabel="Отключить исследование"
/>
) : null}
</ChatPromptInputTools>
<ChatPromptInputSubmit disabled={!canSend} status="ready" />
</ChatPromptInputToolbar>
</ChatPromptInput>
<p className="px-1 text-muted-foreground text-xs">
{research
? "Режим исследования включён — наведите на чип, чтобы отключить."
: "Режим исследования выключен — включите его в меню инструментов."}
</p>
</div>
);
}
import { ChatPromptInputResearchChip } from "@/components/ui/chat-prompt-input";
import { TrendingUpIcon } from "lucide-react";
{research ? (
<ChatPromptInputResearchChip
icon={<TrendingUpIcon aria-hidden="true" />}
label="Research"
onRemove={() => setResearch(false)}
removeLabel="Turn off research"
/>
) : null}Полный композер (навыки, коннекторы, усилие, модели)
Самый близкий к рабочей странице вариант: меню + с подменю Навыки / Коннекторы (MenuSubTrigger), листовыми пунктами и тумблерами Исследование / Веб-поиск; выбор модели с двухстрочными строками ChatPromptInputModelRow (имя + слоган + галочка), подменю Усилие с уровнями, бейджем ChatPromptInputDefaultBadge и подсказкой для максимального уровня, тумблером Мышление, подменю Больше моделей; и ChatPromptInputDisclaimer в подвале.
Oracul может допускать ошибки. Проверяйте важную информацию.
"use client";
import {
IconAlertTriangle,
IconCheck,
IconComponents,
IconInfoCircle,
IconPaperclip,
IconPlus,
IconScript,
IconSettings,
IconTrendingUp,
IconWorld,
} from "@tabler/icons-react";
import { useState } from "react";
import {
ChatPromptInput,
ChatPromptInputDefaultBadge,
ChatPromptInputDisclaimer,
ChatPromptInputMenuTrigger,
ChatPromptInputModelRow,
ChatPromptInputModelTrigger,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import {
Menu,
MenuCheckboxItem,
MenuItem,
MenuPopup,
MenuSeparator,
MenuSub,
MenuSubPopup,
MenuSubTrigger,
MenuTrigger,
} from "@/components/ui/menu";
type Model = { id: string; name: string; tagline: string };
const TOP_MODELS: [Model, ...Model[]] = [
{ id: "opus", name: "Oracul Opus", tagline: "Самая мощная модель" },
{
id: "sonnet",
name: "Oracul Sonnet",
tagline: "Баланс скорости и качества",
},
];
const OTHER_MODELS: Model[] = [
{ id: "haiku", name: "Oracul Haiku", tagline: "Быстрые ответы" },
{ id: "legacy", name: "Oracul 2.1", tagline: "Прошлое поколение" },
];
const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const;
type Effort = (typeof EFFORT_LEVELS)[number];
const EFFORT_LABELS: Record<Effort, string> = {
high: "Высокое",
low: "Низкое",
max: "Максимальное",
medium: "Среднее",
};
const DEFAULT_EFFORT: Effort = "medium";
export default function Particle() {
const [value, setValue] = useState("");
const [modelId, setModelId] = useState("opus");
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
const [thinking, setThinking] = useState(false);
const [research, setResearch] = useState(false);
const [webSearch, setWebSearch] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const canSend = value.trim().length > 0;
const allModels = [...TOP_MODELS, ...OTHER_MODELS];
const [firstModel] = TOP_MODELS;
const selected = allModels.find((m) => m.id === modelId) ?? firstModel;
const otherModels = OTHER_MODELS.filter((m) => m.id !== modelId);
const handleSend = () => {
setIsGenerating(true);
setValue("");
window.setTimeout(() => setIsGenerating(false), 1600);
};
return (
<div className="flex w-full max-w-xl flex-col gap-3 p-4">
<ChatPromptInput elevation="raised" onMessageSend={handleSend}>
<ChatPromptInputTextarea
onChange={(event) => setValue(event.target.value)}
placeholder="Спроси что-нибудь…"
value={value}
/>
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Menu>
<MenuTrigger
render={
<ChatPromptInputMenuTrigger
aria-label="Добавить контент"
icon={<IconPlus aria-hidden="true" stroke={1.5} />}
/>
}
/>
<MenuPopup align="start" alignOffset={-10} className="min-w-64">
<MenuItem>
<IconPaperclip aria-hidden="true" stroke={1.5} />
Добавить файл или фото
</MenuItem>
<MenuSub>
<MenuSubTrigger>
<IconScript aria-hidden="true" stroke={1.5} />
Навыки
</MenuSubTrigger>
<MenuSubPopup align="end" className="min-w-56">
<MenuItem>Анализ данных</MenuItem>
<MenuItem>Веб-разработка</MenuItem>
<MenuSeparator />
<MenuItem>
<IconSettings aria-hidden="true" stroke={1.5} />
Управление навыками
</MenuItem>
<MenuItem>
<IconPlus aria-hidden="true" stroke={1.5} />
Добавить навык
</MenuItem>
</MenuSubPopup>
</MenuSub>
<MenuSub>
<MenuSubTrigger>
<IconComponents aria-hidden="true" stroke={1.5} />
Коннекторы
<span className="ms-auto flex items-center gap-1 text-warning-foreground text-xs">
<IconAlertTriangle
aria-hidden="true"
className="!size-5"
stroke={1.5}
/>
1
</span>
</MenuSubTrigger>
<MenuSubPopup align="end" className="min-w-72">
<MenuCheckboxItem checked variant="switch">
<span className="flex min-w-0 items-center gap-3">
GitHub
</span>
</MenuCheckboxItem>
<MenuCheckboxItem checked={false} variant="switch">
<span className="flex min-w-0 items-center gap-3">
Notion
<IconAlertTriangle
aria-hidden="true"
className="!size-5 ms-auto text-warning"
stroke={1.5}
/>
</span>
</MenuCheckboxItem>
<MenuSeparator />
<MenuItem>
<IconSettings aria-hidden="true" stroke={1.5} />
Управление коннекторами
</MenuItem>
</MenuSubPopup>
</MenuSub>
<MenuSeparator />
<MenuCheckboxItem
checked={research}
onCheckedChange={(v) => setResearch(v === true)}
variant="switch"
>
<span className="flex items-center gap-2.5">
<IconTrendingUp
aria-hidden="true"
className="!size-5"
stroke={1.5}
/>
Исследование
</span>
</MenuCheckboxItem>
<MenuCheckboxItem
checked={webSearch}
onCheckedChange={(v) => setWebSearch(v === true)}
variant="switch"
>
<span className="flex items-center gap-2.5">
<IconWorld
aria-hidden="true"
className="!size-5 text-info"
stroke={1.5}
/>
Веб-поиск
</span>
</MenuCheckboxItem>
</MenuPopup>
</Menu>
</ChatPromptInputTools>
<div className="flex items-center gap-2">
<Menu>
<MenuTrigger
render={
<ChatPromptInputModelTrigger
label={selected.name}
suffix={
effort !== DEFAULT_EFFORT
? EFFORT_LABELS[effort].toLowerCase()
: undefined
}
/>
}
/>
<MenuPopup align="end" side="top">
{TOP_MODELS.map((m) => (
<ChatPromptInputModelRow
inline
key={m.id}
name={m.name}
onClick={() => setModelId(m.id)}
selected={m.id === modelId}
tagline={m.tagline}
/>
))}
<MenuSeparator />
<MenuSub>
<MenuSubTrigger>
<span className="flex-1 truncate text-foreground">
Усилие
</span>
<span className="text-muted-foreground text-xs leading-4">
{EFFORT_LABELS[effort]}
</span>
</MenuSubTrigger>
<MenuSubPopup className="w-80">
<p className="px-2 py-1 text-muted-foreground text-xs leading-4">
Чем выше усилие, тем тщательнее ответ, но дольше и быстрее
расходует лимиты.
</p>
{EFFORT_LEVELS.map((level) => (
<MenuItem key={level} onClick={() => setEffort(level)}>
<span className="flex flex-1 items-center gap-2">
{EFFORT_LABELS[level]}
{level === DEFAULT_EFFORT ? (
<ChatPromptInputDefaultBadge>
По умолчанию
</ChatPromptInputDefaultBadge>
) : null}
{level === "max" ? (
<IconInfoCircle
aria-hidden="true"
className="size-3 shrink-0 text-muted-foreground"
stroke={1.5}
/>
) : null}
</span>
{effort === level ? (
<IconCheck
aria-hidden="true"
className="!size-5 text-info"
stroke={1.5}
/>
) : null}
</MenuItem>
))}
<MenuSeparator />
<MenuCheckboxItem
checked={thinking}
className="py-1.5"
onCheckedChange={(v) => setThinking(v === true)}
variant="switch"
>
<span className="flex flex-col">
<span className="text-foreground text-sm">
Мышление
</span>
<span className="text-muted-foreground text-xs leading-4">
Думает над более сложными задачами
</span>
</span>
</MenuCheckboxItem>
</MenuSubPopup>
</MenuSub>
{otherModels.length > 0 ? (
<>
<MenuSeparator />
<MenuSub>
<MenuSubTrigger>
<span className="flex-1 truncate text-foreground">
Больше моделей
</span>
</MenuSubTrigger>
<MenuSubPopup>
{otherModels.map((m) => (
<ChatPromptInputModelRow
key={m.id}
name={m.name}
onClick={() => setModelId(m.id)}
selected={false}
/>
))}
</MenuSubPopup>
</MenuSub>
</>
) : null}
</MenuPopup>
</Menu>
<ChatPromptInputSubmit
disabled={!isGenerating && !canSend}
status={isGenerating ? "streaming" : "ready"}
/>
</div>
</ChatPromptInputToolbar>
</ChatPromptInput>
<ChatPromptInputDisclaimer>
Oracul может допускать ошибки. Проверяйте важную информацию.
</ChatPromptInputDisclaimer>
</div>
);
}
import {
ChatPromptInputDefaultBadge,
ChatPromptInputDisclaimer,
ChatPromptInputModelRow,
} from "@/components/ui/chat-prompt-input";
import { MenuItem, MenuSeparator, MenuSub, MenuSubPopup, MenuSubTrigger } from "@/components/ui/menu";
<MenuPopup align="end" side="top">
{/* Двухстрочные строки модели: имя + tagline + ведущий Check */}
<ChatPromptInputModelRow
inline
name="Oracul Opus"
onClick={() => setModel("opus")}
selected={model === "opus"}
tagline="Самая мощная модель"
/>
<MenuSeparator />
<MenuSub>
<MenuSubTrigger>
<span className="flex-1 truncate text-foreground">Усилие</span>
<span className="text-muted-foreground text-xs leading-4">Среднее</span>
</MenuSubTrigger>
<MenuSubPopup className="w-80">
<MenuItem onClick={() => setEffort("medium")}>
<span className="flex flex-1 items-center gap-2">
Среднее
<ChatPromptInputDefaultBadge>По умолчанию</ChatPromptInputDefaultBadge>
</span>
</MenuItem>
</MenuSubPopup>
</MenuSub>
</MenuPopup>
<ChatPromptInputDisclaimer>
Oracul может допускать ошибки. Проверяйте важную информацию.
</ChatPromptInputDisclaimer>Подсветка команд и коннекторов
Вместо ChatPromptInputTextarea можно использовать SlashHighlightedTextarea — реальный композер чата: /команды и @коннекторы подсвечиваются, при наведении показывают карточку (ChatTokenChip → описание + тип), а по клику открывают модалку. Подсвечиваются только реальные токены из commands / connectors.
"use client";
import { IconMicrophone, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
ChatConnectorDetail,
type ConnectorToolGroup,
type ConnectorToolPerm,
} from "@/components/ui/chat-connector-detail";
import {
ChatPromptInput,
ChatPromptInputSubmit,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
import {
ChatSkillDoc,
type SkillDocMode,
} from "@/components/ui/chat-skill-doc";
import { Dialog, DialogPopup } from "@/components/ui/dialog";
import { MenuItem } from "@/components/ui/menu";
import {
SlashHighlightedTextarea,
type SlashTokenMeta,
} from "@/components/ui/slash-highlighted-textarea";
const SKILL_BODY = `## Analyst
Ты аналитик Oracul. Превращаешь запрос пользователя в проверяемые выводы из реальных данных.
### Как выбираешь tools
Список доступных tools — единственный источник возможностей. Не угадывай имена «по смыслу» — выбирай по description.`;
const tokenMeta: SlashTokenMeta = {
connectors: {
miro: {
description:
"Официальный MCP-сервер Miro (mcp.miro.com). Доступ к доскам через OAuth.",
typeLabel: "Коннектор",
},
notion: {
description: "Поиск и чтение страниц рабочего пространства Notion.",
typeLabel: "Коннектор",
},
},
skills: {
analyst: {
description:
"Бизнес- и продуктовая аналитика: поиск инсайтов, проверка гипотез, построение отчётов.",
typeLabel: "Навык",
},
},
};
const COMMANDS = Object.keys(tokenMeta.skills ?? {});
const CONNECTORS = Object.keys(tokenMeta.connectors ?? {});
const CONNECTOR_INFO: Record<
string,
{ name: string; connected: boolean; icon: React.ReactNode }
> = {
miro: {
connected: false,
icon: (
<span className="flex size-full items-center justify-center bg-[oklch(0.82_0.16_90)] font-semibold text-black text-xs">
M
</span>
),
name: "Miro",
},
notion: {
connected: true,
icon: (
<span className="flex size-full items-center justify-center bg-foreground font-semibold text-background text-xs">
N
</span>
),
name: "Notion",
},
};
const NOTION_GROUPS: ConnectorToolGroup[] = [
{
id: "read",
label: "Только чтение",
tools: [
{
name: "notion_search",
perm: "allow",
description: "Search pages and databases in the workspace.",
},
{
name: "notion_get_page",
perm: "allow",
description: "Read a page's content by id.",
},
],
},
{
id: "write",
label: "Запись / удаление",
tools: [
{
name: "notion_create_page",
perm: "approval",
description: "Create a new page.",
},
{
name: "notion_update_page",
perm: "approval",
description: "Update an existing page.",
},
],
},
];
type OpenToken = { kind: "skill" | "connector"; id: string };
export default function Particle() {
const [value, setValue] = useState(
"/analyst разбери воронку, сверься с @miro и @notion",
);
const [token, setToken] = useState<OpenToken | null>(null);
const [skillMode, setSkillMode] = useState<SkillDocMode>("rendered");
const [notionGroups, setNotionGroups] = useState(NOTION_GROUPS);
const conn = token?.kind === "connector" ? token.id : null;
const connMeta = conn ? tokenMeta.connectors?.[conn] : undefined;
const connData = conn ? CONNECTOR_INFO[conn] : undefined;
const connected = connData?.connected ?? false;
const setToolPerm = (
groupId: string,
toolName: string,
perm: ConnectorToolPerm,
) =>
setNotionGroups((gs) =>
gs.map((g) =>
g.id === groupId
? {
...g,
tools: g.tools.map((t) =>
t.name === toolName ? { ...t, perm } : t,
),
}
: g,
),
);
const setGroupPolicy = (
groupId: string,
perm: ConnectorToolPerm | "custom",
) =>
setNotionGroups((gs) =>
gs.map((g) =>
g.id === groupId && perm !== "custom"
? { ...g, tools: g.tools.map((t) => ({ ...t, perm })) }
: g,
),
);
return (
<div className="w-full max-w-xl p-4">
<ChatPromptInput onMessageSend={() => {}}>
<SlashHighlightedTextarea
commands={COMMANDS}
connectors={CONNECTORS}
onChange={(e) => setValue(e.target.value)}
onTokenOpen={(kind, id) => setToken({ id, kind })}
tokenMeta={tokenMeta}
value={value}
/>
<ChatPromptInputToolbar>
<ChatPromptInputTools>
<Button aria-label="Прикрепить" size="icon-sm" variant="ghost">
<IconPaperclip aria-hidden="true" stroke={1.5} />
</Button>
<Button aria-label="Голос" size="icon-sm" variant="ghost">
<IconMicrophone aria-hidden="true" stroke={1.5} />
</Button>
</ChatPromptInputTools>
<ChatPromptInputSubmit status="ready" />
</ChatPromptInputToolbar>
</ChatPromptInput>
{/* Skill token → SKILL.md viewer */}
<Dialog
onOpenChange={(open) => {
if (!open) setToken(null);
}}
open={token?.kind === "skill"}
>
<DialogPopup
className="gap-0 overflow-hidden p-0 sm:max-w-[min(92vw,720px)]"
showCloseButton={false}
>
<div className="h-[70vh] max-h-140">
<ChatSkillDoc
body={SKILL_BODY}
description={tokenMeta.skills?.analyst?.description}
mode={skillMode}
onClose={() => setToken(null)}
onCopy={() => {}}
onModeChange={setSkillMode}
source={`---\nname: analyst\nversion: 2.0.0\n---\n${SKILL_BODY}`}
version="v2.0.0"
/>
</div>
</DialogPopup>
</Dialog>
{/* Connector token → detail modal: @miro not connected, @notion connected */}
<Dialog
onOpenChange={(open) => {
if (!open) setToken(null);
}}
open={token?.kind === "connector"}
>
<DialogPopup
className="gap-0 overflow-hidden p-0 sm:max-w-[min(92vw,640px)]"
showCloseButton={false}
>
<div className="h-[64vh] max-h-130">
<ChatConnectorDetail
connected={connected}
description={connMeta?.description}
groups={connected ? notionGroups : undefined}
icon={connData?.icon}
menu={
connected ? (
<>
<MenuItem>Подробности</MenuItem>
<MenuItem>Обновить список инструментов</MenuItem>
<MenuItem variant="destructive">Удалить</MenuItem>
</>
) : undefined
}
name={connData?.name ?? conn ?? ""}
onClose={() => setToken(null)}
onConnect={() => setToken(null)}
onDisconnect={() => setToken(null)}
onGroupPolicyChange={setGroupPolicy}
onToolPermChange={setToolPerm}
/>
</div>
</DialogPopup>
</Dialog>
</div>
);
}
Состояния отправки и авторост
Все состояния ChatPromptInputSubmit рядом плюс демо авто-роста textarea.
Матрица статусов отправки
Авто-рост textarea
"use client";
import { useState } from "react";
import {
ChatPromptInput,
type ChatPromptInputStatus,
ChatPromptInputSubmit,
ChatPromptInputTextarea,
ChatPromptInputToolbar,
ChatPromptInputTools,
} from "@/components/ui/chat-prompt-input";
const STATUSES: { status: ChatPromptInputStatus; label: string }[] = [
{ label: "ready", status: "ready" },
{ label: "submitted", status: "submitted" },
{ label: "streaming", status: "streaming" },
{ label: "error", status: "error" },
];
export default function Particle() {
const [value, setValue] = useState(
"Эта строка длинная и многострочная,\nчтобы показать авто-рост textarea —\nот minHeight 48 до maxHeight 200, затем скролл.",
);
return (
<div className="flex w-full max-w-xl flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
Матрица статусов отправки
</p>
<div className="flex flex-wrap items-center gap-3">
{STATUSES.map(({ status, label }) => (
<div className="flex flex-col items-center gap-1.5" key={label}>
<ChatPromptInputSubmit status={status} type="button" />
<span className="text-muted-foreground text-xs">{label}</span>
</div>
))}
<div className="flex flex-col items-center gap-1.5">
<ChatPromptInputSubmit disabled status="ready" type="button" />
<span className="text-muted-foreground text-xs">disabled</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">Авто-рост textarea</p>
<ChatPromptInput elevation="flat">
<ChatPromptInputTextarea
onChange={(event) => setValue(event.target.value)}
placeholder="Печатай, чтобы увидеть авто-рост…"
value={value}
/>
<ChatPromptInputToolbar>
<ChatPromptInputTools />
<ChatPromptInputSubmit
disabled={value.trim().length === 0}
status="ready"
/>
</ChatPromptInputToolbar>
</ChatPromptInput>
</div>
</div>
);
}
Поведение
- Enter — отправка формы.
- Shift + Enter — новая строка.
- Textarea авто-растёт от
minHeight=48доmaxHeight=200(настраивается через props). - После отправки поле ввода очищается и снова получает фокус.
- Фокус-кольцо живёт на обёртке формы (
focus-within), а не на самом textarea. elevationуправляет глубиной тени:raised(новый чат,0 4px 20px) по умолчанию,flat(внутри чата,0 2px 10px).
Состояния
ChatPromptInputSubmit
- ready / error — вариант
default(primary), иконкаArrowUp,rounded-full, 32×32. - streaming / submitted — вариант
outline(плоский), иконка остановкиSquare. - disabled —
disabled(например!isGenerating && !canSend) даётopacity-64+pointer-events-none. statusвсегда проброшен вdata-status(включаяerror, чьи визуалы совпадают сready).
ChatPromptInputModelTrigger
- rest / hover — ghost-кнопка, при наведении
bg-accent;openуправляется меню. - with-suffix —
suffixрендерится приглушённымfont-normalспаном (усилие / extended). showChevron(по умолчаниюtrue) добавляетChevronDownсopacity-60.
ChatPromptInputResearchChip
- rest — только иконка 28×28,
bg-info/10,text-info-foreground. - hover —
bg-info/15, расширяется до авто-ширины, показывает лейбл и крестик удаления (когда заданonRemove). - focus-visible —
ring-2 ring-ring. tone(enum, по умолчаниюinfo) сохраняет компонент переиспользуемым.
ChatPromptInputMenuTrigger / ChatPromptInputAttachmentTrigger
- rest / hover / open — ghost icon-sm 28×28.
MenuTrigger— для меню вложений,AttachmentTrigger— для прямого выбора файла (скрытый<input type="file">).
ChatPromptInputModelRow
- rest —
MenuItemс раскладкойgrid-cols-[1fr_auto]: имя слева, завершающая колонка справа. - selected — завершающий
Check!size-5 text-info. - unselected — спейсер
size-3.5вместо галочки (строки выровнены). - inline — двухстрочная раскладка: имя
text-foreground+ слоганtext-xsприглушённый (pb-[7.5px]). - highlighted —
data-highlightedbg-accent(наследуется отMenuItem).
ChatPromptInputDefaultBadge
- Тонкая обёртка над
Badge(size="sm",variant="secondary"). Универсальный лейбл — работает для любой пилюли «default» (По умолчанию).
ChatPromptInputDisclaimer
- Центрированный приглушённый
<p>text-xs. Позиционирование (рабочееabsolute bottom-4) намеренно отброшено — это забота потребителя, отвечающего за раскладку.
API
| Компонент | Описание |
|---|---|
ChatPromptInput | Обёртка <form>. onMessageSend: (text) => void, elevation: 'raised' | 'flat'. Очищает textarea при отправке. |
ChatPromptInputTextarea | Авто-растущий textarea. Принимает minHeight, maxHeight. |
ChatPromptInputToolbar | Строка раскладки под textarea (левый кластер + правый кластер). |
ChatPromptInputTools | Левая группа инструментов (gap-1). |
ChatPromptInputSubmit | Кнопка отправки/остановки. status: 'ready' | 'submitted' | 'streaming' | 'error'. |
ChatPromptInputMenuTrigger | Универсальный ghost-триггер меню с иконкой. icon, обязательный aria-label. |
ChatPromptInputAttachmentTrigger | Прямой выбор файла. onFiles, accept, multiple. |
ChatPromptInputModelTrigger | Универсальный ghost-триггер выбора модели. label, suffix, showChevron. |
ChatPromptInputResearchChip | Пилюля активного режима. icon, label, onRemove, removeLabel, tone. |
ChatPromptInputModelRow | Строка выбора модели (MenuItem). name, tagline, selected, inline. |
ChatPromptInputDefaultBadge | Пилюля «default» (Badge sm/secondary). Принимает children + props Badge. |
ChatPromptInputDisclaimer | Центрированная приглушённая подпись <p>. Принимает children / className. |
ChatPromptInputModelSelect | Устарел — Select-пикер для обратной совместимости. Используйте ChatPromptInputModelTrigger + Menu. |
На этой странице