Ai Llm Chat
block communicationChatGPT/Claude-style LLM chat surface. Collapsible left sidebar with New chat, searchable thread history grouped Today/Yesterday/Previous 7 days/Earlier, account row. Centered conversation: empty state with suggested-prompt tiles, user/assistant turns with code-block rendering and copy/regenerate/feedback actions, streaming dots, stop button. Composer with model picker (Opus/Sonnet/Haiku), attach, Enter-to-send, autosizing textarea. Stateful demo — wire `send()` to your streaming endpoint.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/ai-llm-chat.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/ai-llm-chat.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/ai-llm-chat.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/ai-llm-chat.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/ai-llm-chat
Examples
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Turn interface Turn {
id: string
role: Role
body: string
code?: { lang: string; content: string }
createdAt: Date
} Thread interface Thread {
id: string
title: string
updatedAt: Date
turns: Turn[]
} npm dependencies
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border Files (1)
-
app/components/blocks/AiLlmChat.vue 22.4 kB
<script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue' import { ArrowUp, Check, ChevronDown, Copy, MessageSquarePlus, MoreHorizontal, Paperclip, PanelLeftClose, RefreshCw, Search, Sparkles, Square, ThumbsDown, ThumbsUp, Code, Lightbulb, PenLine, BookOpen, } from 'lucide-vue-next' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' type Role = 'user' | 'assistant' interface Turn { id: string role: Role body: string code?: { lang: string; content: string } createdAt: Date } interface Thread { id: string title: string updatedAt: Date turns: Turn[] } const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) const sevenDays = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) const suggestions = [ { icon: Code, label: 'Explain this code', body: 'Walk me through what `useRegistry()` does line by line.' }, { icon: PenLine, label: 'Draft a release note', body: 'Write a release note for shipping the new inbox + chat blocks.', }, { icon: Lightbulb, label: 'Brainstorm', body: 'Give me five ideas for verticals to build on top of the uipkge registry.', }, { icon: BookOpen, label: 'Summarise', body: 'Summarise the CLAUDE.md "Primitive vs Block" boundary in three bullets.', }, ] const models = [ { id: 'opus-4-7', label: 'Opus 4.7', hint: 'Most capable' }, { id: 'sonnet-4-6', label: 'Sonnet 4.6', hint: 'Balanced' }, { id: 'haiku-4-5', label: 'Haiku 4.5', hint: 'Fastest' }, ] const threads = ref<Thread[]>([ { id: 't1', title: 'Designing the registry block boundary', updatedAt: new Date(now.getTime() - 12 * 60 * 1000), turns: [ { id: 'u1', role: 'user', body: 'When should I extract a repeating tile pattern across blocks into a primitive?', createdAt: new Date(now.getTime() - 22 * 60 * 1000), }, { id: 'a1', role: 'assistant', body: 'Short answer: almost never until you have 5+ distinct blocks calling for the *exact same* shape, and the extraction is slot-only with no layout opinion.\n\nThe trap is that StatCard-style primitives — ones that bake an "icon top-right, value below title, trend pill bottom-right" layout — look reusable but lock every block that consumes them. The user installing the block can\'t swap one tile into a chart-led tile without un-abstracting the primitive first.\n\nThe heuristic the CLAUDE.md spells out:', createdAt: new Date(now.getTime() - 21 * 60 * 1000), }, { id: 'a1b', role: 'assistant', body: 'Here is the minimal check you can run on any candidate primitive:', code: { lang: 'ts', content: '// If the prop changes BEHAVIOR -> primitive (variant, disabled, multiple)\n// If the prop changes LAYOUT or WHICH FIELDS RENDER -> block territory\n//\n// Red flag: a primitive that accepts an `items` array of structured records\n// and renders them with a fixed shape. That is layout opinion in a prop.\ninterface KpiItem { title: string; value: string; icon: any; trend: number }\n// ^ if you see this on a `registry:ui` item, stop.', }, createdAt: new Date(now.getTime() - 20 * 60 * 1000), }, { id: 'u2', role: 'user', body: 'And if the same 3 tiles repeat inside one block file?', createdAt: new Date(now.getTime() - 14 * 60 * 1000), }, { id: 'a2', role: 'assistant', body: 'Leave them inline. The registry rule is "three similar lines is better than a premature abstraction." Blocks are supposed to read top-to-bottom at the call site — that is the whole reason a user installs a block instead of a primitive. The cost of writing the tile out three times is small; the cost of pre-abstracting and getting the shape slightly wrong is high (every consumer downstream pays for it).', createdAt: new Date(now.getTime() - 13 * 60 * 1000), }, ], }, { id: 't2', title: 'Tailwind v4 OKLCH dark mode tokens', updatedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), turns: [], }, { id: 't3', title: 'Zero-downtime migration plan', updatedAt: yesterday, turns: [], }, { id: 't4', title: 'Composing kanban + calendar in one view', updatedAt: new Date(now.getTime() - 30 * 60 * 60 * 1000), turns: [], }, { id: 't5', title: 'shadcn-vue resolver circular warning', updatedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000), turns: [], }, { id: 't6', title: 'Cloudflare Pages preview env wiring', updatedAt: new Date(now.getTime() - 9 * 24 * 60 * 60 * 1000), turns: [], }, ]) const activeId = ref<string>('t1') const draft = ref('') const sidebarOpen = ref(true) const sidebarSearch = ref('') const modelOpen = ref(false) const activeModel = ref(models[0]) const isStreaming = ref(false) const copiedId = ref<string | null>(null) const scrollRoot = ref<HTMLElement | null>(null) const activeThread = computed(() => threads.value.find((t) => t.id === activeId.value)!) const turns = computed(() => activeThread.value?.turns ?? []) const isEmpty = computed(() => turns.value.length === 0) const filteredThreads = computed(() => { const q = sidebarSearch.value.trim().toLowerCase() return q ? threads.value.filter((t) => t.title.toLowerCase().includes(q)) : threads.value }) const groupedThreads = computed(() => { const today: Thread[] = [] const ydy: Thread[] = [] const week: Thread[] = [] const earlier: Thread[] = [] for (const t of [...filteredThreads.value].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())) { if (t.updatedAt >= todayStart) today.push(t) else if (t.updatedAt.toDateString() === yesterday.toDateString()) ydy.push(t) else if (t.updatedAt >= sevenDays) week.push(t) else earlier.push(t) } return { today, yesterday: ydy, week, earlier } }) function selectThread(id: string) { activeId.value = id nextTick(scrollToBottom) } function newChat() { const t: Thread = { id: `t${Date.now()}`, title: 'New chat', updatedAt: new Date(), turns: [], } threads.value.unshift(t) activeId.value = t.id draft.value = '' } async function send(prefill?: string) { const body = (prefill ?? draft.value).trim() if (!body || isStreaming.value) return const turn: Turn = { id: `u${Date.now()}`, role: 'user', body, createdAt: new Date(), } activeThread.value.turns.push(turn) activeThread.value.updatedAt = new Date() if (activeThread.value.title === 'New chat') { activeThread.value.title = body.slice(0, 48) + (body.length > 48 ? '…' : '') } draft.value = '' await nextTick() scrollToBottom() isStreaming.value = true window.setTimeout(() => { activeThread.value.turns.push({ id: `a${Date.now()}`, role: 'assistant', body: 'Here is a placeholder response. Wire `send()` up to your streaming endpoint and push assistant tokens onto the last turn as they arrive.', createdAt: new Date(), }) isStreaming.value = false nextTick(scrollToBottom) }, 1400) } function stop() { isStreaming.value = false } function copyTurn(id: string, body: string) { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(body).catch(() => {}) } copiedId.value = id window.setTimeout(() => { if (copiedId.value === id) copiedId.value = null }, 1500) } function onComposerKey(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() send() } } function scrollToBottom() { if (!scrollRoot.value) return scrollRoot.value.scrollTop = scrollRoot.value.scrollHeight } function formatTime(d: Date): string { if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) if (d.toDateString() === yesterday.toDateString()) return 'Yesterday' return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } function selectModel(id: string) { const m = models.find((m) => m.id === id) if (m) activeModel.value = m modelOpen.value = false } watch(activeId, () => nextTick(scrollToBottom)) </script> <template> <div class="bg-background text-foreground grid h-[680px] w-full overflow-hidden rounded-xl border shadow-sm transition-[grid-template-columns]" :style="{ gridTemplateColumns: sidebarOpen ? '260px 1fr' : '0px 1fr' }" > <aside class="bg-muted/30 flex min-w-0 flex-col overflow-hidden border-r"> <div class="flex h-14 shrink-0 items-center gap-2 border-b px-3"> <Button class="h-9 flex-1 justify-start gap-2 rounded-lg" variant="outline" @click="newChat"> <MessageSquarePlus class="size-4" /> New chat </Button> <Button variant="ghost" size="icon" class="size-8 shrink-0" aria-label="Collapse sidebar" @click="sidebarOpen = false" > <PanelLeftClose class="size-4" /> </Button> </div> <div class="px-3 pt-3 pb-2"> <div class="relative"> <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input v-model="sidebarSearch" placeholder="Search chats" class="h-8 rounded-lg pl-8 text-xs" /> </div> </div> <div class="thread-scroll flex-1 overflow-y-auto pb-3"> <template v-for="(group, key) in { Today: groupedThreads.today, Yesterday: groupedThreads.yesterday, 'Previous 7 days': groupedThreads.week, Earlier: groupedThreads.earlier, }" :key="key" > <template v-if="group.length > 0"> <p class="text-muted-foreground px-3 pt-3 pb-1 text-[10px] font-semibold tracking-widest uppercase"> {{ key }} </p> <button v-for="t in group" :key="t.id" :class="[ 'group flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[13px] transition-colors', t.id === activeId ? 'bg-primary/10 text-foreground font-medium' : 'text-foreground/80 hover:bg-muted/60', ]" @click="selectThread(t.id)" > <span class="truncate">{{ t.title }}</span> <MoreHorizontal class="text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" /> </button> </template> </template> </div> <div class="border-t px-3 py-3"> <div class="flex items-center gap-2"> <Avatar class="size-8"> <AvatarFallback class="text-[11px] font-medium">U</AvatarFallback> </Avatar> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">You</p> <p class="text-muted-foreground truncate text-[10px]">Free plan</p> </div> <Button variant="ghost" size="icon" class="size-8" aria-label="Account menu"> <MoreHorizontal class="size-4" /> </Button> </div> </div> </aside> <section class="flex min-w-0 flex-col"> <header class="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4"> <div class="flex items-center gap-2"> <Button v-if="!sidebarOpen" variant="ghost" size="icon" class="size-8" aria-label="Open sidebar" @click="sidebarOpen = true" > <MessageSquarePlus class="size-4" /> </Button> <div class="relative"> <button class="hover:bg-muted/60 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors" @click="modelOpen = !modelOpen" > <Sparkles class="text-primary size-3.5" /> {{ activeModel.label }} <ChevronDown class="text-muted-foreground size-3.5" /> </button> <div v-if="modelOpen" class="bg-popover absolute top-full left-0 z-10 mt-1 w-56 overflow-hidden rounded-lg border p-1 shadow-lg" > <button v-for="m in models" :key="m.id" class="hover:bg-muted/60 flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors" @click="selectModel(m.id)" > <div class="min-w-0"> <p class="font-medium">{{ m.label }}</p> <p class="text-muted-foreground text-[10px]">{{ m.hint }}</p> </div> <Check v-if="m.id === activeModel.id" class="text-primary size-3.5 shrink-0" /> </button> </div> </div> </div> <Button variant="ghost" size="icon" class="size-8" aria-label="More options"> <MoreHorizontal class="size-4" /> </Button> </header> <div ref="scrollRoot" class="thread-scroll flex-1 overflow-y-auto"> <div v-if="isEmpty" class="mx-auto flex h-full max-w-2xl flex-col items-center justify-center gap-6 px-6"> <div class="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full"> <Sparkles class="size-6" /> </div> <div class="text-center"> <h2 class="text-xl font-semibold tracking-tight">How can I help today?</h2> <p class="text-muted-foreground mt-1 text-sm">Ask anything, or try one of these to get started.</p> </div> <div class="grid w-full grid-cols-2 gap-2"> <button v-for="s in suggestions" :key="s.label" class="bg-card hover:bg-muted/60 group flex items-start gap-3 rounded-lg border p-3 text-left transition-colors" @click="send(s.body)" > <div class="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex size-8 shrink-0 items-center justify-center rounded-md transition-colors" > <component :is="s.icon" class="size-4" /> </div> <div class="min-w-0"> <p class="text-foreground text-xs font-semibold">{{ s.label }}</p> <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">{{ s.body }}</p> </div> </button> </div> </div> <div v-else class="mx-auto max-w-3xl space-y-6 px-6 py-6"> <div v-for="t in turns" :key="t.id" class="group"> <template v-if="t.role === 'user'"> <div class="flex justify-end"> <div class="bg-primary/10 text-foreground max-w-[80%] rounded-2xl rounded-tr-md px-4 py-2.5 text-sm leading-relaxed" > {{ t.body }} </div> </div> </template> <template v-else> <div class="flex items-start gap-3"> <div class="bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full" > <Sparkles class="size-3.5" /> </div> <div class="min-w-0 flex-1 space-y-3"> <p class="text-foreground text-sm leading-relaxed whitespace-pre-wrap">{{ t.body }}</p> <div v-if="t.code" class="bg-muted/60 overflow-hidden rounded-lg border"> <div class="bg-muted/80 text-muted-foreground flex items-center justify-between px-3 py-1.5 font-mono text-[10px]" > <span>{{ t.code.lang }}</span> <button class="hover:text-foreground inline-flex items-center gap-1 transition-colors" @click="copyTurn(t.id + '-code', t.code!.content)" > <Check v-if="copiedId === t.id + '-code'" class="text-success size-3" /> <Copy v-else class="size-3" /> {{ copiedId === t.id + '-code' ? 'Copied' : 'Copy' }} </button> </div> <pre class="overflow-x-auto px-3 py-2.5 font-mono text-[12px] leading-relaxed" ><code>{{ t.code.content }}</code></pre> </div> <div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <button class="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors" @click="copyTurn(t.id, t.body)" > <Check v-if="copiedId === t.id" class="text-success size-3" /> <Copy v-else class="size-3" /> {{ copiedId === t.id ? 'Copied' : 'Copy' }} </button> <button class="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors" aria-label="Regenerate" > <RefreshCw class="size-3" /> </button> <button class="text-muted-foreground hover:bg-muted hover:text-success inline-flex size-7 items-center justify-center rounded-md transition-colors" aria-label="Good response" > <ThumbsUp class="size-3" /> </button> <button class="text-muted-foreground hover:bg-muted hover:text-destructive inline-flex size-7 items-center justify-center rounded-md transition-colors" aria-label="Bad response" > <ThumbsDown class="size-3" /> </button> <span class="text-muted-foreground ml-auto text-[10px] tabular-nums">{{ formatTime(t.createdAt) }}</span> </div> </div> </div> </template> </div> <div v-if="isStreaming" class="flex items-start gap-3"> <div class="bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full" > <Sparkles class="size-3.5" /> </div> <div class="text-muted-foreground flex items-center gap-1.5 pt-1.5"> <span class="typing-dot" /> <span class="typing-dot" style="animation-delay: 0.15s" /> <span class="typing-dot" style="animation-delay: 0.3s" /> </div> </div> </div> </div> <div class="bg-muted/20 border-t px-4 py-3"> <div class="mx-auto max-w-3xl"> <div class="bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-all duration-150 focus-within:shadow-md focus-within:ring-4" > <Textarea v-model="draft" :placeholder="`Message ${activeModel.label}`" rows="1" class="placeholder:text-muted-foreground/70 max-h-48 min-h-[48px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0" @keydown="onComposerKey" /> <div class="flex items-center justify-between gap-2 px-2 pb-2"> <div class="flex items-center gap-0.5"> <Button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8" aria-label="Attach file" > <Paperclip class="size-4" /> </Button> <span class="text-muted-foreground bg-muted ml-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[11px] font-medium" > <Sparkles class="text-primary size-3" /> {{ activeModel.label }} </span> </div> <div class="flex items-center gap-2"> <kbd class="bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-[10px] sm:inline-flex" > <span>↵</span> </kbd> <Button v-if="isStreaming" variant="outline" class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" aria-label="Stop generating" @click="stop" > <Square class="size-3 fill-current" /> Stop </Button> <Button v-else class="h-8 gap-1.5 rounded-lg px-3 text-xs font-medium" :disabled="!draft.trim()" aria-label="Send" @click="send()" > Send <ArrowUp class="size-3.5" /> </Button> </div> </div> </div> <p class="text-muted-foreground/70 mt-2 text-center text-[10px]"> AI can make mistakes. Verify important info. </p> </div> </div> </section> </div> </template> <style scoped> .thread-scroll::-webkit-scrollbar { width: 6px; } .thread-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } .typing-dot { display: inline-block; width: 6px; height: 6px; border-radius: 9999px; background: currentColor; opacity: 0.5; animation: typing-bounce 1s infinite ease-in-out; } @keyframes typing-bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.35; } 40% { transform: translateY(-3px); opacity: 0.9; } } </style>
Raw manifest: https://uipkge.dev/r/vue/ai-llm-chat.json