UIPackage

Ai Llm Chat

block communication
Edit on GitHub

ChatGPT/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

$ npx 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