- 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 Attachments
Кластер вложений композера — квадратные плитки image/doc/pasted, цитаты-фрагменты и control удаления со смещением 8px и всеми состояниями загрузки.
Наведите курсор на плитку, чтобы появилась кнопка удаления.
"use client";
import { useState } from "react";
import {
type ChatAttachmentItem,
ChatAttachments,
} from "@/components/ui/chat-attachments";
const initial: ChatAttachmentItem[] = [
{
id: "1",
name: "hero.png",
kind: "image",
url: "https://picsum.photos/seed/oracul-hero/200/200",
thumbnail: "https://picsum.photos/seed/oracul-hero/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 строки",
},
{
id: "4",
name: "Вставленный текст",
kind: "pasted",
url: "blob:pasted",
textContent:
'export function App() {\n return <main className="p-6">Hello</main>;\n}',
},
];
export default function Particle() {
const [attachments, setAttachments] = useState(initial);
return (
<div className="w-full max-w-xl rounded-2xl border bg-card p-3">
<ChatAttachments
attachments={attachments}
badgeHint={(item) =>
item.kind === "doc"
? `Oracul может анализировать файлы ${item.ext ?? "этого типа"}.`
: undefined
}
onPreview={() => {}}
onRemove={(id) =>
setAttachments((items) => items.filter((i) => i.id !== id))
}
removeHint="Удалить"
/>
<p className="px-2 pt-2 text-muted-foreground text-sm">
Наведите курсор на плитку, чтобы появилась кнопка удаления.
</p>
</div>
);
}
Установка
pnpm dlx shadcn@latest add @oracul/chat-attachments
Использование
import {
ChatAttachments,
type ChatAttachmentItem,
} from "@/components/ui/chat-attachments";
const attachments: ChatAttachmentItem[] = [
{ id: "1", name: "hero.png", kind: "image", url, thumbnail: url },
{ id: "2", name: "spec.pdf", kind: "doc", url, ext: "PDF", meta: "8 pages" },
];
<ChatAttachments
attachments={attachments}
onRemove={(id) => remove(id)}
onPreview={(item) => openModal(item)}
onBadgeClick={(item) => openPanel(item)}
/>;Варианты плиток
item.kind определяет, какая плитка отрисовывается. Каждый файл — одинаковый квадрат 120 × 120; фрагмент-цитата — блок цитаты с авто-шириной.
kind | Что отрисовывает |
|---|---|
image | Квадратная миниатюра (object-cover). Падает на центрированный спиннер во время загрузки и на destructive-алерт при ошибке. |
doc | Квадратная карточка — имя (обрезка до 3 строк) + опциональная строка meta + FileBadge с расширением. |
pasted | Квадратная карточка — обрезанный предпросмотр текста + FileBadge с фиксированной меткой (Pasted по умолчанию). |
excerpt | Блок цитаты авто-ширины с левой линией + предпросмотр с обрезкой до 6 строк. |
Состояния
- idle — плитка в покое:
border-foreground/15наbg-card, поднимается доborder-foreground/30при наведении. - uploading — спиннер
size-5на теле карточки;FileBadgeпоказывает спиннерsize-3. - failed —
border-destructive/50 bg-destructive/5(фрагмент:bg-destructive/5 ring-1 ring-destructive/40) с destructive-глифом алерта. - remove — крестик проявляется при наведении на плитку / focus-within и располагается на 8px вне левого верхнего угла. Опусти
onRemoveдля read-only-сценария отправленного сообщения.
"use client";
import { useState } from "react";
import {
type ChatAttachmentItem,
ChatAttachments,
} from "@/components/ui/chat-attachments";
const initial: ChatAttachmentItem[] = [
{
id: "up-img",
name: "upload.png",
kind: "image",
status: "uploading",
},
{
id: "up-doc",
name: "report.pdf",
kind: "doc",
ext: "PDF",
status: "uploading",
},
{
id: "fail-doc",
name: "broken.zip",
kind: "doc",
ext: "ZIP",
status: "failed",
error: "Не удалось загрузить — файл слишком большой",
},
{
id: "excerpt",
name: "Фрагмент из ответа",
kind: "excerpt",
url: "blob:excerpt",
textContent:
"Ряд вложений в поле ввода точно повторяет эталон чата: квадратные плитки 120px для файлов и изображений, блок цитаты с автоматической шириной для фрагментов и кнопка удаления, выступающая на 8px за левый верхний угол.",
},
{
id: "fail-excerpt",
name: "Фрагмент не прикреплён",
kind: "excerpt",
status: "failed",
error: "Не удалось прикрепить фрагмент",
textContent:
"Эту цитату не удалось прикрепить — блок переключается на предупреждающую обводку, сохраняя левую линию и обрезанный предпросмотр.",
},
];
export default function Particle() {
const [attachments, setAttachments] = useState(initial);
return (
<div className="w-full max-w-xl rounded-2xl border bg-card p-3">
<ChatAttachments
attachments={attachments}
onRemove={(id) =>
setAttachments((items) => items.filter((i) => i.id !== id))
}
removeHint="Удалить"
/>
</div>
);
}
Read-only (отправленные сообщения)
Опусти onRemove (и onPreview), чтобы отрисовать статичный, неинтерактивный ряд — без control'а удаления, без внутренних кнопок.
import {
type ChatAttachmentItem,
ChatAttachments,
} from "@/components/ui/chat-attachments";
const attachments: ChatAttachmentItem[] = [
{
id: "img",
name: "diagram.png",
kind: "image",
url: "https://picsum.photos/seed/oracul-sent/200/200",
thumbnail: "https://picsum.photos/seed/oracul-sent/200/200",
},
{
id: "doc",
name: "requirements.pdf",
kind: "doc",
ext: "PDF",
meta: "8 страниц",
},
];
export default function Particle() {
return (
<div className="flex w-full max-w-md flex-col items-end gap-2">
{/* Sent-message attachment row — read-only: no `onRemove`, so no
remove-X is rendered, and no `onPreview`, so tiles are inert. */}
<ChatAttachments attachments={attachments} />
<div className="rounded-2xl rounded-tr-sm bg-secondary px-3.5 py-2 text-secondary-foreground text-sm">
Вот материалы для лендинга.
</div>
</div>
);
}
Превью по клику
Два разных действия на одной плитке:
onPreview(клик по карточке) → центрированная модалкаfile-preview-modal;onBadgeClick(клик по бейджу формата) → боковая панельchat-artifactс шапкой «← имя · мета».
Тело в обоих случаях одно — картинка, исходник (FilePreviewSource), PDF-веер (pdf-thumbnail) или таблица (Table); выбирает его приложение по типу файла.
Клик по карточке → модалка. Клик по бейджу (PDF / MD / XLSX / Вставлено) → правая панель превью.
"use client";
import { IconChevronLeft } from "@tabler/icons-react";
import { useState } from "react";
import {
ChatArtifact,
ChatArtifactAction,
ChatArtifactClose,
ChatArtifactContent,
ChatArtifactDescription,
ChatArtifactHeader,
ChatArtifactInfo,
ChatArtifactTitle,
} from "@/components/ui/chat-artifact";
import {
type ChatAttachmentItem,
ChatAttachments,
} from "@/components/ui/chat-attachments";
import {
FilePreviewModal,
FilePreviewSource,
} from "@/components/ui/file-preview-modal";
import { PdfThumbnail } from "@/components/ui/pdf-thumbnail";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const IMG = `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="272"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#9fd0ff"/><stop offset="1" stop-color="#ffd29f"/></linearGradient></defs><rect width="400" height="272" fill="url(#g)"/><text x="50%" y="50%" font-family="sans-serif" font-size="22" fill="#fff" text-anchor="middle" dominant-baseline="middle">oracul-amocrm</text></svg>`,
)}`;
const PAGE = `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="280" height="280"><rect width="280" height="280" fill="#fff"/><g fill="#e2e0db"><rect x="28" y="30" width="120" height="10" rx="2"/><rect x="28" y="58" width="224" height="6" rx="2"/><rect x="28" y="72" width="224" height="6" rx="2"/><rect x="28" y="86" width="160" height="6" rx="2"/><rect x="28" y="120" width="224" height="6" rx="2"/></g><circle cx="210" cy="225" r="30" fill="none" stroke="#7fb0e0" stroke-width="2"/></svg>`,
)}`;
const MD = `---
name: dance-coach
version: 1.1.0
---
# 💃 Dance Coach
Опытный хореограф и танцевальный педагог: стили,
техника, история, подбор направления.`;
const PASTED = "185.103.71.121:40879@fd136ece5d:9c5fb2f1f6";
const XLSX_ROWS = [
["4", "Flora Delivery", "Email"],
["4", "Flora Delivery", "Telegram"],
["4", "Flora Delivery", "WhatsApp"],
["4", "Flora Delivery", "amoCRM интеграция"],
["4", "Flora Delivery", "ВКонтакте"],
];
const ITEMS: ChatAttachmentItem[] = [
{
id: "img",
name: "oracul-amocrm.jpg",
kind: "image",
url: "blob:img",
thumbnail: IMG,
},
{
id: "pdf",
name: "Счёт №7378.pdf",
kind: "doc",
url: "blob:pdf",
ext: "PDF",
},
{
id: "md",
name: "dance-coach.md",
kind: "doc",
url: "blob:md",
ext: "MD",
meta: "131 строка",
},
{
id: "xlsx",
name: "услуги.xlsx",
kind: "doc",
url: "blob:xlsx",
ext: "XLSX",
},
{
id: "pasted",
name: "Вставленный текст",
kind: "pasted",
url: "blob:pasted",
textContent: PASTED,
},
];
const META: Record<string, string> = {
img: "40.5 KB · image/jpeg",
pdf: "118.5 KB · application/pdf",
md: "8.8 KB · 131 строка · text/markdown",
xlsx: "44.9 KB · application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
pasted: "43 B · 2 строки · text/plain",
};
const SOURCE_NOTE = "Форматирование может отличаться от источника";
function isSource(item: ChatAttachmentItem) {
return (
item.kind === "pasted" ||
(item.kind === "doc" && ["MD", "TXT", "CSV"].includes(item.ext ?? ""))
);
}
function panelDesc(item: ChatAttachmentItem) {
return isSource(item) ? `${META[item.id]} · ${SOURCE_NOTE}` : META[item.id];
}
function XlsxTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID проекта</TableHead>
<TableHead>Имя проекта</TableHead>
<TableHead>Название услуги</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{XLSX_ROWS.map((row) => (
<TableRow key={row.join()}>
{row.map((cell, i) => (
<TableCell key={`${row.join()}-${i}`}>{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
function body(item: ChatAttachmentItem) {
if (item.kind === "image") {
return (
<div className="flex justify-center">
<img
alt=""
className="h-auto w-full rounded-lg object-contain"
src={IMG}
/>
</div>
);
}
if (item.kind === "pasted") {
return <FilePreviewSource>{PASTED}</FilePreviewSource>;
}
if (item.ext === "PDF") {
return (
<PdfThumbnail filename={item.name} pages={1}>
<img alt="" className="block size-full object-cover" src={PAGE} />
</PdfThumbnail>
);
}
if (item.ext === "XLSX") {
return <XlsxTable />;
}
return <FilePreviewSource>{MD}</FilePreviewSource>;
}
export default function Particle() {
const [modalItem, setModalItem] = useState<ChatAttachmentItem | null>(null);
const [panelItem, setPanelItem] = useState<ChatAttachmentItem | null>(null);
return (
<div className="flex h-125 w-full gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-3 rounded-xl border bg-card p-4">
<ChatAttachments
attachments={ITEMS}
onBadgeClick={setPanelItem}
onPreview={setModalItem}
/>
<p className="text-muted-foreground text-sm">
Клик по карточке → модалка. Клик по бейджу (PDF / MD / XLSX /
Вставлено) → правая панель превью.
</p>
</div>
{panelItem ? (
<ChatArtifact className="w-96 shrink-0">
<ChatArtifactHeader>
<div className="flex min-w-0 items-center gap-1">
<ChatArtifactAction
label="Назад"
onClick={() => setPanelItem(null)}
>
<IconChevronLeft stroke={1.5} />
</ChatArtifactAction>
<ChatArtifactInfo>
<ChatArtifactTitle className="truncate">
{panelItem.name}
</ChatArtifactTitle>
<ChatArtifactDescription className="truncate">
{panelDesc(panelItem)}
</ChatArtifactDescription>
</ChatArtifactInfo>
</div>
<ChatArtifactClose onClick={() => setPanelItem(null)} />
</ChatArtifactHeader>
<ChatArtifactContent className="p-4">
{body(panelItem)}
</ChatArtifactContent>
</ChatArtifact>
) : null}
<FilePreviewModal
filename={modalItem?.name}
lightBackground={modalItem?.kind !== "image"}
meta={modalItem ? META[modalItem.id] : undefined}
onOpenChange={(o) => {
if (!o) setModalItem(null);
}}
open={modalItem !== null}
>
{modalItem ? body(modalItem) : null}
</FilePreviewModal>
</div>
);
}
Тип данных
type ChatAttachmentKind = "image" | "doc" | "pasted" | "excerpt";
type ChatAttachmentStatus = "idle" | "uploading" | "failed";
type ChatAttachmentItem = {
id: string; // React-ключ + аргумент onRemove
name: string; // имя файла / отображаемое имя
kind: ChatAttachmentKind; // определяет тело плитки
url?: string; // object URL или удалённый URL
thumbnail?: string; // миниатюра плитки image
ext?: string; // метка FileBadge (например, 'PDF')
meta?: string; // вторичная строка doc (например, '124 lines')
status?: ChatAttachmentStatus;
error?: string; // заголовок плитки failed
textContent?: string; // текст предпросмотра pasted / excerpt
};Преобразуй данные загрузки / черновика в ChatAttachmentItem перед передачей — ни один тип загрузки приложения не пересекает эту границу.
Подкомпоненты
Для кастомных раскладок собирай экспортируемые части напрямую:
ChatAttachment(алиасChatAttachmentTile) — одиночная плитка;item.kindвыбирает тело.ChatAttachmentImage/ChatAttachmentDoc/ChatAttachmentPasted/ChatAttachmentExcerpt— тела для каждого kind.FileBadge— чип формата 18px в верхнем регистре, с опциональным тултипомhint.ChatAttachmentRemove— специальная заливная пилюля-крестик, с опциональным тултипомhint.getChatAttachmentExt(name)— выводит расширение в верхнем регистре из имени файла.