{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "ai-llm-chat",
  "title": "Ai Llm Chat",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/ai-llm-chat/AiLlmChat.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport {\n  ArrowUp,\n  Check,\n  ChevronDown,\n  Copy,\n  MessageSquarePlus,\n  MoreHorizontal,\n  Paperclip,\n  PanelLeftClose,\n  RefreshCw,\n  Search,\n  Sparkles,\n  Square,\n  ThumbsDown,\n  ThumbsUp,\n  Code,\n  Lightbulb,\n  PenLine,\n  BookOpen,\n} from 'lucide-vue-next'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\n\ntype Role = 'user' | 'assistant'\n\ninterface Turn {\n  id: string\n  role: Role\n  body: string\n  code?: { lang: string; content: string }\n  createdAt: Date\n}\n\ninterface Thread {\n  id: string\n  title: string\n  updatedAt: Date\n  turns: Turn[]\n}\n\nconst now = new Date()\nconst todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\nconst yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\nconst sevenDays = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\nconst suggestions = [\n  { icon: Code, label: 'Explain this code', body: 'Walk me through what `useRegistry()` does line by line.' },\n  {\n    icon: PenLine,\n    label: 'Draft a release note',\n    body: 'Write a release note for shipping the new inbox + chat blocks.',\n  },\n  {\n    icon: Lightbulb,\n    label: 'Brainstorm',\n    body: 'Give me five ideas for verticals to build on top of the uipkge registry.',\n  },\n  {\n    icon: BookOpen,\n    label: 'Summarise',\n    body: 'Summarise the CLAUDE.md \"Primitive vs Block\" boundary in three bullets.',\n  },\n]\n\nconst models = [\n  { id: 'opus-4-7', label: 'Opus 4.7', hint: 'Most capable' },\n  { id: 'sonnet-4-6', label: 'Sonnet 4.6', hint: 'Balanced' },\n  { id: 'haiku-4-5', label: 'Haiku 4.5', hint: 'Fastest' },\n]\n\nconst threads = ref<Thread[]>([\n  {\n    id: 't1',\n    title: 'Designing the registry block boundary',\n    updatedAt: new Date(now.getTime() - 12 * 60 * 1000),\n    turns: [\n      {\n        id: 'u1',\n        role: 'user',\n        body: 'When should I extract a repeating tile pattern across blocks into a primitive?',\n        createdAt: new Date(now.getTime() - 22 * 60 * 1000),\n      },\n      {\n        id: 'a1',\n        role: 'assistant',\n        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:',\n        createdAt: new Date(now.getTime() - 21 * 60 * 1000),\n      },\n      {\n        id: 'a1b',\n        role: 'assistant',\n        body: 'Here is the minimal check you can run on any candidate primitive:',\n        code: {\n          lang: 'ts',\n          content:\n            '// 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.',\n        },\n        createdAt: new Date(now.getTime() - 20 * 60 * 1000),\n      },\n      {\n        id: 'u2',\n        role: 'user',\n        body: 'And if the same 3 tiles repeat inside one block file?',\n        createdAt: new Date(now.getTime() - 14 * 60 * 1000),\n      },\n      {\n        id: 'a2',\n        role: 'assistant',\n        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).',\n        createdAt: new Date(now.getTime() - 13 * 60 * 1000),\n      },\n    ],\n  },\n  {\n    id: 't2',\n    title: 'Tailwind v4 OKLCH dark mode tokens',\n    updatedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't3',\n    title: 'Zero-downtime migration plan',\n    updatedAt: yesterday,\n    turns: [],\n  },\n  {\n    id: 't4',\n    title: 'Composing kanban + calendar in one view',\n    updatedAt: new Date(now.getTime() - 30 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't5',\n    title: 'shadcn-vue resolver circular warning',\n    updatedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't6',\n    title: 'Cloudflare Pages preview env wiring',\n    updatedAt: new Date(now.getTime() - 9 * 24 * 60 * 60 * 1000),\n    turns: [],\n  },\n])\n\nconst activeId = ref<string>('t1')\nconst draft = ref('')\nconst sidebarOpen = ref(true)\nconst sidebarSearch = ref('')\nconst modelOpen = ref(false)\nconst activeModel = ref(models[0])\nconst isStreaming = ref(false)\nconst copiedId = ref<string | null>(null)\nconst scrollRoot = ref<HTMLElement | null>(null)\n\nconst activeThread = computed(() => threads.value.find((t) => t.id === activeId.value)!)\nconst turns = computed(() => activeThread.value?.turns ?? [])\nconst isEmpty = computed(() => turns.value.length === 0)\n\nconst filteredThreads = computed(() => {\n  const q = sidebarSearch.value.trim().toLowerCase()\n  return q ? threads.value.filter((t) => t.title.toLowerCase().includes(q)) : threads.value\n})\n\nconst groupedThreads = computed(() => {\n  const today: Thread[] = []\n  const ydy: Thread[] = []\n  const week: Thread[] = []\n  const earlier: Thread[] = []\n  for (const t of [...filteredThreads.value].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())) {\n    if (t.updatedAt >= todayStart) today.push(t)\n    else if (t.updatedAt.toDateString() === yesterday.toDateString()) ydy.push(t)\n    else if (t.updatedAt >= sevenDays) week.push(t)\n    else earlier.push(t)\n  }\n  return { today, yesterday: ydy, week, earlier }\n})\n\nfunction selectThread(id: string) {\n  activeId.value = id\n  nextTick(scrollToBottom)\n}\n\nfunction newChat() {\n  const t: Thread = {\n    id: `t${Date.now()}`,\n    title: 'New chat',\n    updatedAt: new Date(),\n    turns: [],\n  }\n  threads.value.unshift(t)\n  activeId.value = t.id\n  draft.value = ''\n}\n\nasync function send(prefill?: string) {\n  const body = (prefill ?? draft.value).trim()\n  if (!body || isStreaming.value) return\n  const turn: Turn = {\n    id: `u${Date.now()}`,\n    role: 'user',\n    body,\n    createdAt: new Date(),\n  }\n  activeThread.value.turns.push(turn)\n  activeThread.value.updatedAt = new Date()\n  if (activeThread.value.title === 'New chat') {\n    activeThread.value.title = body.slice(0, 48) + (body.length > 48 ? '…' : '')\n  }\n  draft.value = ''\n  await nextTick()\n  scrollToBottom()\n  isStreaming.value = true\n  window.setTimeout(() => {\n    activeThread.value.turns.push({\n      id: `a${Date.now()}`,\n      role: 'assistant',\n      body: 'Here is a placeholder response. Wire `send()` up to your streaming endpoint and push assistant tokens onto the last turn as they arrive.',\n      createdAt: new Date(),\n    })\n    isStreaming.value = false\n    nextTick(scrollToBottom)\n  }, 1400)\n}\n\nfunction stop() {\n  isStreaming.value = false\n}\n\nfunction copyTurn(id: string, body: string) {\n  if (typeof navigator !== 'undefined' && navigator.clipboard) {\n    navigator.clipboard.writeText(body).catch(() => {})\n  }\n  copiedId.value = id\n  window.setTimeout(() => {\n    if (copiedId.value === id) copiedId.value = null\n  }, 1500)\n}\n\nfunction onComposerKey(e: KeyboardEvent) {\n  if (e.key === 'Enter' && !e.shiftKey) {\n    e.preventDefault()\n    send()\n  }\n}\n\nfunction scrollToBottom() {\n  if (!scrollRoot.value) return\n  scrollRoot.value.scrollTop = scrollRoot.value.scrollHeight\n}\n\nfunction formatTime(d: Date): string {\n  if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\nfunction selectModel(id: string) {\n  const m = models.find((m) => m.id === id)\n  if (m) activeModel.value = m\n  modelOpen.value = false\n}\n\nwatch(activeId, () => nextTick(scrollToBottom))\n</script>\n\n<template>\n  <div\n    class=\"bg-background text-foreground grid h-[680px] w-full overflow-hidden rounded-xl border shadow-sm transition-[grid-template-columns]\"\n    :style=\"{ gridTemplateColumns: sidebarOpen ? '260px 1fr' : '0px 1fr' }\"\n  >\n    <aside class=\"bg-muted/30 flex min-w-0 flex-col overflow-hidden border-r\">\n      <div class=\"flex h-14 shrink-0 items-center gap-2 border-b px-3\">\n        <Button class=\"h-9 flex-1 justify-start gap-2 rounded-lg\" variant=\"outline\" @click=\"newChat\">\n          <MessageSquarePlus class=\"size-4\" />\n          New chat\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          class=\"size-8 shrink-0\"\n          aria-label=\"Collapse sidebar\"\n          @click=\"sidebarOpen = false\"\n        >\n          <PanelLeftClose class=\"size-4\" />\n        </Button>\n      </div>\n      <div class=\"px-3 pt-3 pb-2\">\n        <div class=\"relative\">\n          <Search class=\"text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2\" />\n          <Input v-model=\"sidebarSearch\" placeholder=\"Search chats\" class=\"h-8 rounded-lg pl-8 text-xs\" />\n        </div>\n      </div>\n      <div class=\"thread-scroll flex-1 overflow-y-auto pb-3\">\n        <template\n          v-for=\"(group, key) in {\n            Today: groupedThreads.today,\n            Yesterday: groupedThreads.yesterday,\n            'Previous 7 days': groupedThreads.week,\n            Earlier: groupedThreads.earlier,\n          }\"\n          :key=\"key\"\n        >\n          <template v-if=\"group.length > 0\">\n            <p class=\"text-muted-foreground px-3 pt-3 pb-1 text-[10px] font-semibold tracking-widest uppercase\">\n              {{ key }}\n            </p>\n            <button\n              v-for=\"t in group\"\n              :key=\"t.id\"\n              :class=\"[\n                'group flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[13px] transition-colors',\n                t.id === activeId\n                  ? 'bg-primary/10 text-foreground font-medium'\n                  : 'text-foreground/80 hover:bg-muted/60',\n              ]\"\n              @click=\"selectThread(t.id)\"\n            >\n              <span class=\"truncate\">{{ t.title }}</span>\n              <MoreHorizontal\n                class=\"text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\"\n              />\n            </button>\n          </template>\n        </template>\n      </div>\n      <div class=\"border-t px-3 py-3\">\n        <div class=\"flex items-center gap-2\">\n          <Avatar class=\"size-8\">\n            <AvatarFallback class=\"text-[11px] font-medium\">U</AvatarFallback>\n          </Avatar>\n          <div class=\"min-w-0 flex-1\">\n            <p class=\"truncate text-xs font-medium\">You</p>\n            <p class=\"text-muted-foreground truncate text-[10px]\">Free plan</p>\n          </div>\n          <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Account menu\">\n            <MoreHorizontal class=\"size-4\" />\n          </Button>\n        </div>\n      </div>\n    </aside>\n\n    <section class=\"flex min-w-0 flex-col\">\n      <header class=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n        <div class=\"flex items-center gap-2\">\n          <Button\n            v-if=\"!sidebarOpen\"\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"size-8\"\n            aria-label=\"Open sidebar\"\n            @click=\"sidebarOpen = true\"\n          >\n            <MessageSquarePlus class=\"size-4\" />\n          </Button>\n          <div class=\"relative\">\n            <button\n              class=\"hover:bg-muted/60 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors\"\n              @click=\"modelOpen = !modelOpen\"\n            >\n              <Sparkles class=\"text-primary size-3.5\" />\n              {{ activeModel.label }}\n              <ChevronDown class=\"text-muted-foreground size-3.5\" />\n            </button>\n            <div\n              v-if=\"modelOpen\"\n              class=\"bg-popover absolute top-full left-0 z-10 mt-1 w-56 overflow-hidden rounded-lg border p-1 shadow-lg\"\n            >\n              <button\n                v-for=\"m in models\"\n                :key=\"m.id\"\n                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\"\n                @click=\"selectModel(m.id)\"\n              >\n                <div class=\"min-w-0\">\n                  <p class=\"font-medium\">{{ m.label }}</p>\n                  <p class=\"text-muted-foreground text-[10px]\">{{ m.hint }}</p>\n                </div>\n                <Check v-if=\"m.id === activeModel.id\" class=\"text-primary size-3.5 shrink-0\" />\n              </button>\n            </div>\n          </div>\n        </div>\n        <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n          <MoreHorizontal class=\"size-4\" />\n        </Button>\n      </header>\n\n      <div ref=\"scrollRoot\" class=\"thread-scroll flex-1 overflow-y-auto\">\n        <div v-if=\"isEmpty\" class=\"mx-auto flex h-full max-w-2xl flex-col items-center justify-center gap-6 px-6\">\n          <div class=\"bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full\">\n            <Sparkles class=\"size-6\" />\n          </div>\n          <div class=\"text-center\">\n            <h2 class=\"text-xl font-semibold tracking-tight\">How can I help today?</h2>\n            <p class=\"text-muted-foreground mt-1 text-sm\">Ask anything, or try one of these to get started.</p>\n          </div>\n          <div class=\"grid w-full grid-cols-2 gap-2\">\n            <button\n              v-for=\"s in suggestions\"\n              :key=\"s.label\"\n              class=\"bg-card hover:bg-muted/60 group flex items-start gap-3 rounded-lg border p-3 text-left transition-colors\"\n              @click=\"send(s.body)\"\n            >\n              <div\n                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\"\n              >\n                <component :is=\"s.icon\" class=\"size-4\" />\n              </div>\n              <div class=\"min-w-0\">\n                <p class=\"text-foreground text-xs font-semibold\">{{ s.label }}</p>\n                <p class=\"text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed\">{{ s.body }}</p>\n              </div>\n            </button>\n          </div>\n        </div>\n\n        <div v-else class=\"mx-auto max-w-3xl space-y-6 px-6 py-6\">\n          <div v-for=\"t in turns\" :key=\"t.id\" class=\"group\">\n            <template v-if=\"t.role === 'user'\">\n              <div class=\"flex justify-end\">\n                <div\n                  class=\"bg-primary/10 text-foreground max-w-[80%] rounded-2xl rounded-tr-md px-4 py-2.5 text-sm leading-relaxed\"\n                >\n                  {{ t.body }}\n                </div>\n              </div>\n            </template>\n            <template v-else>\n              <div class=\"flex items-start gap-3\">\n                <div\n                  class=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\"\n                >\n                  <Sparkles class=\"size-3.5\" />\n                </div>\n                <div class=\"min-w-0 flex-1 space-y-3\">\n                  <p class=\"text-foreground text-sm leading-relaxed whitespace-pre-wrap\">{{ t.body }}</p>\n                  <div v-if=\"t.code\" class=\"bg-muted/60 overflow-hidden rounded-lg border\">\n                    <div\n                      class=\"bg-muted/80 text-muted-foreground flex items-center justify-between px-3 py-1.5 font-mono text-[10px]\"\n                    >\n                      <span>{{ t.code.lang }}</span>\n                      <button\n                        class=\"hover:text-foreground inline-flex items-center gap-1 transition-colors\"\n                        @click=\"copyTurn(t.id + '-code', t.code!.content)\"\n                      >\n                        <Check v-if=\"copiedId === t.id + '-code'\" class=\"text-success size-3\" />\n                        <Copy v-else class=\"size-3\" />\n                        {{ copiedId === t.id + '-code' ? 'Copied' : 'Copy' }}\n                      </button>\n                    </div>\n                    <pre\n                      class=\"overflow-x-auto px-3 py-2.5 font-mono text-[12px] leading-relaxed\"\n                    ><code>{{ t.code.content }}</code></pre>\n                  </div>\n                  <div class=\"flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100\">\n                    <button\n                      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\"\n                      @click=\"copyTurn(t.id, t.body)\"\n                    >\n                      <Check v-if=\"copiedId === t.id\" class=\"text-success size-3\" />\n                      <Copy v-else class=\"size-3\" />\n                      {{ copiedId === t.id ? 'Copied' : 'Copy' }}\n                    </button>\n                    <button\n                      class=\"text-muted-foreground hover:bg-muted hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                      aria-label=\"Regenerate\"\n                    >\n                      <RefreshCw class=\"size-3\" />\n                    </button>\n                    <button\n                      class=\"text-muted-foreground hover:bg-muted hover:text-success inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                      aria-label=\"Good response\"\n                    >\n                      <ThumbsUp class=\"size-3\" />\n                    </button>\n                    <button\n                      class=\"text-muted-foreground hover:bg-muted hover:text-destructive inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                      aria-label=\"Bad response\"\n                    >\n                      <ThumbsDown class=\"size-3\" />\n                    </button>\n                    <span class=\"text-muted-foreground ml-auto text-[10px] tabular-nums\">{{\n                      formatTime(t.createdAt)\n                    }}</span>\n                  </div>\n                </div>\n              </div>\n            </template>\n          </div>\n\n          <div v-if=\"isStreaming\" class=\"flex items-start gap-3\">\n            <div\n              class=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\"\n            >\n              <Sparkles class=\"size-3.5\" />\n            </div>\n            <div class=\"text-muted-foreground flex items-center gap-1.5 pt-1.5\">\n              <span class=\"typing-dot\" />\n              <span class=\"typing-dot\" style=\"animation-delay: 0.15s\" />\n              <span class=\"typing-dot\" style=\"animation-delay: 0.3s\" />\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-muted/20 border-t px-4 py-3\">\n        <div class=\"mx-auto max-w-3xl\">\n          <div\n            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\"\n          >\n            <Textarea\n              v-model=\"draft\"\n              :placeholder=\"`Message ${activeModel.label}`\"\n              rows=\"1\"\n              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\"\n              @keydown=\"onComposerKey\"\n            />\n            <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n              <div class=\"flex items-center gap-0.5\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Attach file\"\n                >\n                  <Paperclip class=\"size-4\" />\n                </Button>\n                <span\n                  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\"\n                >\n                  <Sparkles class=\"text-primary size-3\" />\n                  {{ activeModel.label }}\n                </span>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <kbd\n                  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\"\n                >\n                  <span>↵</span>\n                </kbd>\n                <Button\n                  v-if=\"isStreaming\"\n                  variant=\"outline\"\n                  class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                  aria-label=\"Stop generating\"\n                  @click=\"stop\"\n                >\n                  <Square class=\"size-3 fill-current\" />\n                  Stop\n                </Button>\n                <Button\n                  v-else\n                  class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                  :disabled=\"!draft.trim()\"\n                  aria-label=\"Send\"\n                  @click=\"send()\"\n                >\n                  Send\n                  <ArrowUp class=\"size-3.5\" />\n                </Button>\n              </div>\n            </div>\n          </div>\n          <p class=\"text-muted-foreground/70 mt-2 text-center text-[10px]\">\n            AI can make mistakes. Verify important info.\n          </p>\n        </div>\n      </div>\n    </section>\n  </div>\n</template>\n\n<style scoped>\n.thread-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n.thread-scroll::-webkit-scrollbar-thumb {\n  background: var(--border);\n  border-radius: 3px;\n}\n.typing-dot {\n  display: inline-block;\n  width: 6px;\n  height: 6px;\n  border-radius: 9999px;\n  background: currentColor;\n  opacity: 0.5;\n  animation: typing-bounce 1s infinite ease-in-out;\n}\n@keyframes typing-bounce {\n  0%,\n  80%,\n  100% {\n    transform: translateY(0);\n    opacity: 0.35;\n  }\n  40% {\n    transform: translateY(-3px);\n    opacity: 0.9;\n  }\n}\n</style>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/AiLlmChat.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/avatar.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/input.json",
    "https://uipkge.dev/r/vue/textarea.json"
  ],
  "description": "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.",
  "categories": [
    "communication",
    "ai"
  ]
}