{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-citation",
  "title": "Inline Citation",
  "author": "Jamie Thompson <jamie@jamiethompson.design> (https://jamiethompson.design)",
  "description": "Inline citation badge for AI-generated text: a hostname pill such as \"ai-sdk.dev +2\" placed after a passage that opens a source card on hover, keyboard focus or a click/tap, containing a carousel of sources (title, URL, description, quote) with prev/next paging and an index. Use inside streamed responses to attribute claims to web or document sources.",
  "dependencies": [
    "cn",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "carousel",
    "hover-card"
  ],
  "files": [
    {
      "path": "registry/ai/inline-citation.tsx",
      "content": "// Derived from Vercel AI Elements inline-citation.tsx (Apache-2.0, Copyright 2023 Vercel, Inc.).\n// Modified for uifiles: ported from Radix UI to Base UI; dependencies point at @uifiles.\n\"use client\"\n\nimport { cn } from \"cn\"\nimport { ArrowLeftIcon, ArrowRightIcon } from \"lucide-react\"\nimport type {\n  ComponentProps,\n  KeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n  ReactNode,\n  RefObject,\n} from \"react\"\nimport {\n  Children,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport { Badge } from \"@/components/ui/badge\"\nimport type { CarouselApi } from \"@/components/ui/carousel\"\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n} from \"@/components/ui/carousel\"\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\"\n\nexport type InlineCitationProps = ComponentProps<\"span\">\n\nexport const InlineCitation = ({\n  className,\n  ...props\n}: InlineCitationProps) => (\n  <span\n    className={cn(\"group inline items-center gap-1\", className)}\n    {...props}\n  />\n)\n\nexport type InlineCitationTextProps = ComponentProps<\"span\">\n\nexport const InlineCitationText = ({\n  className,\n  ...props\n}: InlineCitationTextProps) => (\n  <span\n    className={cn(\"transition-colors group-hover:bg-accent\", className)}\n    {...props}\n  />\n)\n\nexport type InlineCitationCardProps = ComponentProps<typeof HoverCard>\n\ntype InlineCitationCardChangeEventDetails = Parameters<\n  NonNullable<InlineCitationCardProps[\"onOpenChange\"]>\n>[1]\n\ntype InlineCitationCardContextValue = {\n  open: boolean\n  /** Opened, or kept open, by a press: focus belongs inside the card. */\n  pinned: boolean\n  press: (event: ReactMouseEvent<HTMLElement>) => void\n  popupRef: RefObject<HTMLDivElement | null>\n  /** The body's id, for the badge's aria-controls. */\n  popupId: string\n  /** Lets the body report an id the consumer gave it. */\n  setPopupIdOverride: (id: string | undefined) => void\n  /** The pressed badge's id, which names the pinned card. */\n  triggerId: string | undefined\n}\n\nconst InlineCitationCardContext =\n  createContext<InlineCitationCardContextValue | null>(null)\n\n// Base UI Preview Card opens on hover and on keyboard focus only, so a press\n// gets change details built here in the shape Base UI gives every reason.\nconst pressEventDetails = (\n  event: MouseEvent,\n  trigger: Element\n): InlineCitationCardChangeEventDetails => {\n  let isCanceled = false\n  let isPropagationAllowed = false\n  return {\n    reason: \"trigger-press\",\n    event,\n    trigger,\n    get isCanceled() {\n      return isCanceled\n    },\n    get isPropagationAllowed() {\n      return isPropagationAllowed\n    },\n    cancel: () => {\n      isCanceled = true\n    },\n    allowPropagation: () => {\n      isPropagationAllowed = true\n    },\n    preventUnmountOnClose: () => {},\n  }\n}\n\n// Base UI puts hover delays on the trigger, not the root: see\n// InlineCitationCardTrigger. The open state lives here so a press (click,\n// tap, Enter, Space) can open the card: Base UI Preview Card has hover and\n// keyboard-focus interactions only, which leaves touch users nothing.\nexport const InlineCitationCard = ({\n  open: openProp,\n  defaultOpen,\n  onOpenChange,\n  ...props\n}: InlineCitationCardProps) => {\n  const onOpenChangeRef = useRef(onOpenChange)\n  useEffect(() => {\n    onOpenChangeRef.current = onOpenChange\n  })\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false)\n  const isControlled = openProp !== undefined\n  const open = isControlled ? openProp : uncontrolled\n  const [pinned, setPinned] = useState(false)\n  // A closed card is never pinned. A press a controlled parent refused (its\n  // `open` stayed false) or a pinned card the parent closed through `open`\n  // would otherwise make the card's next opening modal.\n  if (pinned && !open) setPinned(false)\n  const [triggerId, setTriggerId] = useState<string>()\n  const generatedPopupId = useId()\n  const [popupIdOverride, setPopupIdOverride] = useState<string>()\n  const popupId = popupIdOverride ?? generatedPopupId\n  const triggerRef = useRef<HTMLElement | null>(null)\n  const popupRef = useRef<HTMLDivElement | null>(null)\n  const restoreFocusRef = useRef(false)\n  // Focus returned to the badge by the card must not count as the badge\n  // receiving focus, until the badge is blurred or pressed again.\n  const ignoreFocusOpenRef = useRef(false)\n\n  const setOpen = useCallback(\n    (next: boolean, details: InlineCitationCardChangeEventDetails) => {\n      if (!isControlled) setUncontrolled(next)\n      onOpenChangeRef.current?.(next, details)\n    },\n    [isControlled]\n  )\n\n  const handleOpenChange = useCallback(\n    (next: boolean, details: InlineCitationCardChangeEventDetails) => {\n      if (next) {\n        if (details.reason === \"trigger-focus\" && ignoreFocusOpenRef.current) {\n          details.cancel()\n          return\n        }\n        if (details.trigger instanceof HTMLElement) {\n          triggerRef.current = details.trigger\n        }\n        setOpen(true, details)\n        return\n      }\n      if (details.reason === \"trigger-focus\") {\n        ignoreFocusOpenRef.current = false\n      }\n      // A pinned card outlives the pointer leaving the badge; it closes on\n      // Escape, an outside press or a second press.\n      if (pinned && details.reason === \"trigger-hover\") {\n        details.cancel()\n        return\n      }\n      // Focus goes back to the badge when it was in the card, or when a\n      // pinned card lost it to <body> (content that removed or disabled its\n      // own focused control); focus the user put elsewhere stays there.\n      const active = document.activeElement\n      restoreFocusRef.current =\n        details.reason === \"escape-key\" &&\n        (popupRef.current?.contains(active) === true ||\n          (pinned && (active === null || active === document.body)))\n      setPinned(false)\n      setOpen(false, details)\n    },\n    [pinned, setOpen]\n  )\n\n  const press = useCallback(\n    (event: ReactMouseEvent<HTMLElement>) => {\n      const details = pressEventDetails(event.nativeEvent, event.currentTarget)\n      triggerRef.current = event.currentTarget\n      setTriggerId(event.currentTarget.id || undefined)\n      ignoreFocusOpenRef.current = false\n      if (open && pinned) {\n        restoreFocusRef.current = true\n        setPinned(false)\n        setOpen(false, details)\n        return\n      }\n      setPinned(true)\n      if (!open) setOpen(true, details)\n    },\n    [open, pinned, setOpen]\n  )\n\n  // Runs after Base UI has handled the dismissal, so its own Escape block is\n  // in place; the ignore flag covers a press-close and a delayed focus open.\n  useEffect(() => {\n    if (open || !restoreFocusRef.current) return\n    restoreFocusRef.current = false\n    ignoreFocusOpenRef.current = true\n    triggerRef.current?.focus()\n  }, [open])\n\n  const contextValue = useMemo<InlineCitationCardContextValue>(\n    () => ({\n      open,\n      pinned: open && pinned,\n      popupId,\n      popupRef,\n      press,\n      setPopupIdOverride,\n      triggerId,\n    }),\n    [open, pinned, popupId, press, triggerId]\n  )\n\n  return (\n    <InlineCitationCardContext.Provider value={contextValue}>\n      <HoverCard onOpenChange={handleOpenChange} open={open} {...props} />\n    </InlineCitationCardContext.Provider>\n  )\n}\n\nexport type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> &\n  Pick<ComponentProps<typeof HoverCardTrigger>, \"delay\" | \"closeDelay\"> & {\n    sources: string[]\n  }\n\n// Sources come from model output, so relative paths (\"/docs/streaming\") and\n// bare hosts (\"example.com\") are common; `new URL` throws on both. A\n// try/catch rather than URL.canParse, which Safari before 17 lacks.\nconst safeHostname = (source: string) => {\n  try {\n    return new URL(source).hostname || source\n  } catch {\n    return source\n  }\n}\n\nexport const InlineCitationCardTrigger = ({\n  sources,\n  className,\n  delay = 0,\n  closeDelay = 0,\n  onClick,\n  ...props\n}: InlineCitationCardTriggerProps) => {\n  const card = useContext(InlineCitationCardContext)\n  const [first] = sources\n  const label = first\n    ? `${safeHostname(first)}${sources.length > 1 ? ` +${sources.length - 1}` : \"\"}`\n    : \"unknown\"\n\n  return (\n    <HoverCardTrigger\n      closeDelay={closeDelay}\n      delay={delay}\n      // A <span> is skipped by Tab. A button is focusable, so Base UI's\n      // focus path opens the card for keyboard and assistive-tech users.\n      render={\n        <Badge\n          // Base UI's Preview Card trigger carries no ARIA; a press opens\n          // the card, so the badge is a disclosure button for it.\n          {...(card && {\n            \"aria-controls\": card.open ? card.popupId : undefined,\n            \"aria-expanded\": card.open,\n          })}\n          className={cn(\"ml-1 rounded-full\", className)}\n          render={<button type=\"button\" />}\n          variant=\"secondary\"\n          {...props}\n          onClick={(event) => {\n            onClick?.(event)\n            if (!event.defaultPrevented) card?.press(event)\n          }}\n        />\n      }\n    >\n      {label}\n    </HoverCardTrigger>\n  )\n}\n\nexport type InlineCitationCardBodyProps = ComponentProps<\n  typeof HoverCardContent\n>\n\nconst TABBABLE =\n  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\n// Tab and Shift+Tab stay inside the card while focus is in it: the popup is\n// portaled to <body>, so the browser's own order would leave the page.\n// Escape closes the card and returns focus to the badge.\nconst cycleTab = (event: KeyboardEvent<HTMLDivElement>) => {\n  if (event.key !== \"Tab\") return\n  const popup = event.currentTarget\n  const items = Array.from(popup.querySelectorAll<HTMLElement>(TABBABLE))\n  const first = items[0]\n  const last = items[items.length - 1]\n  if (!first || !last) {\n    event.preventDefault()\n    popup.focus()\n    return\n  }\n  const active = document.activeElement\n  if (event.shiftKey ? active === popup || active === first : active === last) {\n    event.preventDefault()\n    ;(event.shiftKey ? last : first).focus()\n  }\n}\n\n// Mounts with the popup, so the effect runs once the card is in the DOM and\n// again when a press pins a card that hover or focus already opened.\nconst PinnedFocus = ({\n  pinned,\n  popupRef,\n}: Pick<InlineCitationCardContextValue, \"pinned\" | \"popupRef\">) => {\n  useEffect(() => {\n    const popup = popupRef.current\n    if (pinned && popup && !popup.contains(document.activeElement)) {\n      popup.focus()\n    }\n  }, [pinned, popupRef])\n  return null\n}\n\nexport const InlineCitationCardBody = ({\n  className,\n  children,\n  onKeyDown,\n  ref,\n  ...props\n}: InlineCitationCardBodyProps) => {\n  const card = useContext(InlineCitationCardContext)\n  const popupRef = card?.popupRef\n  const setPopupIdOverride = card?.setPopupIdOverride\n  const { id: idProp } = props\n\n  useEffect(() => {\n    setPopupIdOverride?.(idProp)\n    return () => setPopupIdOverride?.(undefined)\n  }, [setPopupIdOverride, idProp])\n\n  // A peek on hover or focus is non-modal and keeps focus on the badge; a\n  // pinned card takes focus, so it is a dialog named after its badge.\n  const pinned = card?.pinned === true\n  const named =\n    props[\"aria-label\"] !== undefined || props[\"aria-labelledby\"] !== undefined\n\n  const setPopup = useCallback(\n    (node: HTMLDivElement | null) => {\n      if (popupRef) popupRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [popupRef, ref]\n  )\n\n  return (\n    <HoverCardContent\n      className={cn(\n        \"relative w-80 p-0 focus-visible:ring-3 focus-visible:ring-ring/50\",\n        className\n      )}\n      id={card?.popupId}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!event.defaultPrevented) cycleTab(event)\n      }}\n      ref={setPopup}\n      {...(pinned && {\n        role: \"dialog\",\n        ...(!named &&\n          (card?.triggerId\n            ? { \"aria-labelledby\": card.triggerId }\n            : { \"aria-label\": \"Sources\" })),\n      })}\n      {...props}\n    >\n      {children}\n      {card ? (\n        <PinnedFocus pinned={card.pinned} popupRef={card.popupRef} />\n      ) : null}\n    </HoverCardContent>\n  )\n}\n\nconst CarouselApiContext = createContext<CarouselApi | undefined>(undefined)\n\nconst useCarouselApi = () => {\n  const context = useContext(CarouselApiContext)\n  return context\n}\n\ntype CarouselSnap = {\n  current: number\n  count: number\n  canScrollPrev: boolean\n  canScrollNext: boolean\n}\n\nconst emptySnap: CarouselSnap = {\n  current: 0,\n  count: 0,\n  canScrollPrev: false,\n  canScrollNext: false,\n}\n\n// Adding or removing slides makes embla re-initialize (`reInit`,\n// `slidesChanged`) without emitting `select`, so all three events resync.\nconst snapEvents = [\"select\", \"reInit\", \"slidesChanged\"] as const\n\nconst useCarouselSnap = (): CarouselSnap => {\n  const api = useCarouselApi()\n  const [snap, setSnap] = useState(emptySnap)\n\n  useEffect(() => {\n    if (!api) {\n      return\n    }\n\n    const sync = () => {\n      const count = api.scrollSnapList().length\n      setSnap({\n        current: count === 0 ? 0 : api.selectedScrollSnap() + 1,\n        count,\n        canScrollPrev: api.canScrollPrev(),\n        canScrollNext: api.canScrollNext(),\n      })\n    }\n\n    sync()\n\n    for (const event of snapEvents) {\n      api.on(event, sync)\n    }\n\n    return () => {\n      for (const event of snapEvents) {\n        api.off(event, sync)\n      }\n    }\n  }, [api])\n\n  return snap\n}\n\nexport type InlineCitationCarouselProps = ComponentProps<typeof Carousel>\n\nexport const InlineCitationCarousel = ({\n  className,\n  children,\n  setApi: consumerSetApi,\n  ...props\n}: InlineCitationCarouselProps) => {\n  const [api, setApi] = useState<CarouselApi>()\n\n  const handleSetApi = useCallback(\n    (nextApi: CarouselApi) => {\n      setApi(nextApi)\n      consumerSetApi?.(nextApi)\n    },\n    [consumerSetApi]\n  )\n\n  return (\n    <CarouselApiContext.Provider value={api}>\n      <Carousel\n        className={cn(\"w-full\", className)}\n        setApi={handleSetApi}\n        {...props}\n      >\n        {children}\n      </Carousel>\n    </CarouselApiContext.Provider>\n  )\n}\n\nexport type InlineCitationCarouselContentProps = ComponentProps<\"div\">\n\nexport const InlineCitationCarouselContent = (\n  props: InlineCitationCarouselContentProps\n) => <CarouselContent {...props} />\n\nexport type InlineCitationCarouselItemProps = ComponentProps<\"div\">\n\nexport const InlineCitationCarouselItem = ({\n  className,\n  ...props\n}: InlineCitationCarouselItemProps) => (\n  <CarouselItem\n    className={cn(\"w-full space-y-2 p-4 pl-8\", className)}\n    {...props}\n  />\n)\n\nexport type InlineCitationCarouselHeaderProps = ComponentProps<\"div\">\n\nexport const InlineCitationCarouselHeader = ({\n  className,\n  ...props\n}: InlineCitationCarouselHeaderProps) => (\n  <div\n    className={cn(\n      \"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2\",\n      className\n    )}\n    {...props}\n  />\n)\n\nexport type InlineCitationCarouselIndexProps = ComponentProps<\"div\">\n\nexport const InlineCitationCarouselIndex = ({\n  children,\n  className,\n  ...props\n}: InlineCitationCarouselIndexProps) => {\n  const { current, count } = useCarouselSnap()\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-1 items-center justify-end px-3 py-1 text-xs text-secondary-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? `${current}/${count}`}\n    </div>\n  )\n}\n\n// size-6 gives the 16px icon the 24px hit area WCAG 2.5.8 asks for. At an\n// end an arrow is aria-disabled rather than disabled (embla ignores the\n// click there): a disabled button cannot hold focus, so a keyboard user\n// paging to the last slide would be dropped to <body>.\nconst carouselButtonClassName =\n  \"inline-flex size-6 shrink-0 items-center justify-center rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 aria-disabled:opacity-50\"\n\nexport type InlineCitationCarouselPrevProps = ComponentProps<\"button\">\n\nexport const InlineCitationCarouselPrev = ({\n  className,\n  ...props\n}: InlineCitationCarouselPrevProps) => {\n  const api = useCarouselApi()\n  const { canScrollPrev } = useCarouselSnap()\n\n  const handleClick = useCallback(() => {\n    if (api) {\n      api.scrollPrev()\n    }\n  }, [api])\n\n  return (\n    <button\n      aria-disabled={!canScrollPrev}\n      aria-label=\"Previous\"\n      className={cn(carouselButtonClassName, className)}\n      onClick={handleClick}\n      type=\"button\"\n      {...props}\n    >\n      <ArrowLeftIcon className=\"size-4 text-muted-foreground\" />\n    </button>\n  )\n}\n\nexport type InlineCitationCarouselNextProps = ComponentProps<\"button\">\n\nexport const InlineCitationCarouselNext = ({\n  className,\n  ...props\n}: InlineCitationCarouselNextProps) => {\n  const api = useCarouselApi()\n  const { canScrollNext } = useCarouselSnap()\n\n  const handleClick = useCallback(() => {\n    if (api) {\n      api.scrollNext()\n    }\n  }, [api])\n\n  return (\n    <button\n      aria-disabled={!canScrollNext}\n      aria-label=\"Next\"\n      className={cn(carouselButtonClassName, className)}\n      onClick={handleClick}\n      type=\"button\"\n      {...props}\n    >\n      <ArrowRightIcon className=\"size-4 text-muted-foreground\" />\n    </button>\n  )\n}\n\n// React renders nothing for null, booleans, empty strings, empty arrays and\n// whitespace-only text, which would leave an empty element (and a quote's\n// border) behind.\nconst hasContent = (children: ReactNode) =>\n  Children.toArray(children).some(\n    (child) => typeof child !== \"string\" || child.trim() !== \"\"\n  )\n\nexport type InlineCitationSourceProps = ComponentProps<\"div\"> & {\n  title?: string | undefined\n  url?: string | undefined\n  description?: string | undefined\n}\n\nexport const InlineCitationSource = ({\n  title,\n  url,\n  description,\n  className,\n  children,\n  ...props\n}: InlineCitationSourceProps) => {\n  if (!title && !url && !description && !hasContent(children)) {\n    return null\n  }\n\n  return (\n    <div className={cn(\"space-y-1\", className)} {...props}>\n      {title && (\n        <p className=\"truncate text-sm leading-tight font-medium\">{title}</p>\n      )}\n      {url && (\n        <p className=\"truncate text-xs break-all text-muted-foreground\">\n          {url}\n        </p>\n      )}\n      {description && (\n        <p className=\"line-clamp-3 text-sm leading-relaxed text-muted-foreground\">\n          {description}\n        </p>\n      )}\n      {children}\n    </div>\n  )\n}\n\nexport type InlineCitationQuoteProps = ComponentProps<\"blockquote\">\n\nexport const InlineCitationQuote = ({\n  children,\n  className,\n  ...props\n}: InlineCitationQuoteProps) => {\n  if (!hasContent(children)) {\n    return null\n  }\n\n  return (\n    <blockquote\n      className={cn(\n        \"border-l-2 border-muted pl-3 text-sm text-muted-foreground italic\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </blockquote>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ai/inline-citation.tsx"
    }
  ],
  "docs": "Derived from Vercel AI Elements (Apache-2.0, Copyright 2023 Vercel, Inc.); ported to Base UI and maintained by uifiles. Built for the Base UI styles (base-nova): it composes the Base UI wrappers from components/ui and relies on their render prop and event signatures, so it does not work on Radix or React Aria styles. Base UI Preview Card puts hover delays on the trigger, not the root: InlineCitationCard no longer accepts openDelay/closeDelay; pass delay/closeDelay to InlineCitationCardTrigger instead (both default 0). InlineCitationCardTrigger composes HoverCardTrigger via render={<Badge render={<button type=\"button\" />} />} (no asChild): the badge is a real button, so it is in the Tab order and focus opens the card; remaining props (including onClick, which runs first and can preventDefault the toggle) still go to the Badge. InlineCitationCard keeps the open state itself (open/defaultOpen/onOpenChange still work and a press reports reason \"trigger-press\"): hover and keyboard focus peek at the card as in Base UI Preview Card, and a press on the badge (click, tap, Enter, Space) opens or pins it and moves focus to InlineCitationCardBody, where Tab and Shift+Tab cycle through the card's controls; a pinned card stays open when the pointer leaves and closes on Escape (focus returns to the badge without reopening it, also when the control that had focus inside the card went away), on an outside press or on a second press. With a controlled `open`, a press pins the card only if your `onOpenChange` opens it in the same update; a press you refuse leaves nothing behind, and a card you close through `open` drops its pin, so the next time you open it, it is the non-modal peek. Base UI Preview Card adds no ARIA to its trigger, so the badge carries aria-expanded and, while the card is open, aria-controls pointing at InlineCitationCardBody (an id you pass to the body is followed). A peek on hover or focus stays non-modal; a pinned card is role=\"dialog\", named by the badge through aria-labelledby (an aria-label or aria-labelledby you pass to the body wins), and shows a focus ring when keyboard focus lands on it. Upstream's Radix HoverCard has no press or touch path. A source that is not an absolute URL (relative path, bare host) is shown as given instead of throwing (try/catch, no URL.canParse, so Safari before 17 renders). InlineCitationCardBody is typed as the hover-card content props, so side/align/sideOffset/alignOffset type-check. InlineCitationCarouselPrev/Next are aria-disabled (not disabled) and dimmed at the first/last slide unless opts={{ loop: true }}, so a keyboard user who pages to an end keeps focus on the arrow and both arrows stay in the Tab order; a click there does nothing, and a disabled or aria-disabled prop you pass wins. They have a 24px hit area and a focus ring; InlineCitationCarouselIndex follows slides added while streaming and reads 0/0 with none. A setApi passed to InlineCitationCarousel is called alongside the internal one. InlineCitationSource and InlineCitationQuote render nothing when they have no content (undefined, an empty or whitespace-only string, an empty array, or only null/boolean children). InlineCitationSource renders its title as <p> instead of <h4> so a hover card never breaks the page's heading order. InlineCitationCarouselIndex uses text-secondary-foreground on the bg-secondary header for AA contrast.",
  "categories": [
    "ai",
    "chat"
  ],
  "type": "registry:component"
}