Skip to content

How-To: Extend and Customize the Ragbits Chat UI#

This guide covers advanced customization of the Ragbits Chat UI, including using decoupled components, creating plugins, and customizing the store implementation.

Using Decoupled Components in Your Project#

The UI components are designed to be decoupled and reusable in external projects. This section explains how to integrate them into a new application.

Required Packages#

Copy the dependencies from the project's package.json. The key packages are:

UI & Styling:

{
  "@heroui/react": "^2.8.1",
  "@heroicons/react": "^2.2.0",
  "framer-motion": "^12.23.6",
  "tailwindcss": "^4.1.11",
  "@tailwindcss/postcss": "^4.1.11",
  "@tailwindcss/vite": "^4.1.11",
  "@tailwindcss/typography": "^0.5.16"
}

Core Dependencies:

{
  "@ragbits/api-client-react": "*",
  "react": "^18.3.1",
  "react-dom": "^18.3.1",
  "react-router": "^7.7.1",
  "zustand": "^5.0.6",
  "immer": "^10.1.1",
  "uuid": "^11.1.0"
}

Provider Hierarchy#

Set up your application with the following provider structure:

import { StrictMode } from "react";
import { HeroUIProvider } from "@heroui/react";
import { RagbitsContextProvider } from "@ragbits/api-client-react";
import { ThemeContextProvider } from "./core/contexts/ThemeContext/ThemeContextProvider";
import HistoryStoreContextProvider from "./core/stores/HistoryStore/HistoryStoreContextProvider";
import App from "./App";

const API_URL = "https://your-api.com";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <HeroUIProvider>
      <RagbitsContextProvider baseUrl={API_URL}>
        <ThemeContextProvider>
          <HistoryStoreContextProvider>
            <div className="bg-background flex h-screen w-screen items-start justify-center">
              <div className="h-full w-full max-w-full">
                <App />
              </div>
            </div>
          </HistoryStoreContextProvider>
        </ThemeContextProvider>
      </RagbitsContextProvider>
    </HeroUIProvider>
  </StrictMode>,
);

Provider Descriptions#

Provider Purpose
HeroUIProvider HeroUI component library context for theming and accessibility
RagbitsContextProvider Provides the Ragbits API client with the configured base URL
ThemeContextProvider Manages light/dark theme switching with localStorage persistence
HistoryStoreContextProvider Manages chat history and conversation state

Setting Up Styles#

1. Copy globals.css#

Copy the src/globals.css file to your project and import it in your entry point:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "../hero.ts";

/* NOTE: Update this path based on your project structure */
@source "../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";
@custom-variant dark (&:is(.dark *));

@theme {
  --breakpoint-xs: 28rem;
  --animate-pop-in: pop-in 0.2s ease-out forwards;

  @keyframes pop-in {
    0% {
      transform: scale(0.8);
      opacity: 0;
    }
    100% {
      transform: scale(1);
      opacity: 1;
    }
  }
}

.markdown-container code::before,
.markdown-container code::after {
  content: none;
}

.prose {
  overflow-wrap: break-word;
}

2. Update HeroUI Theme Path#

The @source directive path may need to be updated based on your project structure:

/* Original path (for this project's structure) */
@source "../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";

/* Example: If globals.css is in src/ */
@source "../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";

/* Example: If globals.css is in src/styles/ */
@source "../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";

3. Copy HeroUI Plugin#

Copy the hero.ts file to your project root (or update the @plugin path in globals.css):

// hero.ts
import { heroui } from "@heroui/react";
export default heroui();

Store Customization#

The HistoryStoreContextProvider accepts a storeInitializer prop for dependency injection, allowing you to customize the store behavior.

Using the storeInitializer Prop#

import HistoryStoreContextProvider from "./core/stores/HistoryStore/HistoryStoreContextProvider";
import { createStore } from "zustand";
import { immer } from "zustand/middleware/immer";

// Create a custom store initializer
const createCustomHistoryStore = immer((set, get) => ({
  // ... your custom store implementation
  conversations: {},
  currentConversation: "default",

  actions: {
    sendMessage: async (text, ragbitsClient) => {
      // Custom message handling logic
      const response = await myCustomAPI.chat(text);
      // Update state...
    },
    newConversation: () => {
      // Custom conversation creation
    },
    // ... other actions
  },

  primitives: {
    getCurrentConversation: () =>
      get().conversations[get().currentConversation],
    addMessage: (conversationId, message) => {
      // Add message to conversation
    },
    // ... other primitives
  },

  _internal: {
    _hasHydrated: true,
    _setHasHydrated: () => {},
    handleResponse: () => {},
  },
}));

// Use your custom initializer
<HistoryStoreContextProvider storeInitializer={createCustomHistoryStore}>
  <App />
</HistoryStoreContextProvider>;

Store Initializer Interface#

The store must implement the HistoryStore interface:

interface HistoryStore {
  conversations: Record<string, Conversation>;
  currentConversation: string;

  computed: {
    getContext: () => Record<string, unknown>;
  };

  actions: {
    newConversation: () => string;
    selectConversation: (conversationId: string) => void;
    deleteConversation: (conversationId: string) => void;
    sendMessage: (
      text: string,
      ragbitsClient: RagbitsClient,
      additionalContext?: Record<string, unknown>,
    ) => void;
    stopAnswering: () => void;
    // ... other actions
  };

  primitives: {
    addMessage: (
      conversationId: string,
      message: Omit<ChatMessage, "id">,
    ) => string;
    deleteMessage: (conversationId: string, messageId: string) => void;
    getCurrentConversation: () => Conversation;
    // ... other primitives
  };

  _internal: {
    _hasHydrated: boolean;
    _setHasHydrated: (state: boolean) => void;
    handleResponse: (conversationIdRef, messageId, response) => void;
  };
}

Waiting for Hydration#

If your store needs async initialization (e.g., loading from IndexedDB), use the waitForHydration prop:

<HistoryStoreContextProvider
  storeInitializer={createPersistentStore}
  waitForHydration={true}
>
  {/* App will show loading screen until store is hydrated */}
  <App />
</HistoryStoreContextProvider>

Minimal Setup (Without Custom Store)#

For basic usage, HistoryStoreContextProvider provides a minimal in-memory store by default:

<HistoryStoreContextProvider>
  {/* Uses built-in minimal store */}
  <App />
</HistoryStoreContextProvider>

This minimal store:

  • Stores conversations in memory only (no persistence)
  • Provides basic conversation management
  • Does not make API calls (you need to handle that separately)

Using History Store Hooks#

Access the store in your components:

import { useHistoryStore, useHistoryActions } from "./core/stores/HistoryStore/selectors";

function ChatComponent() {
  // Get actions
  const { sendMessage, newConversation, stopAnswering } = useHistoryActions();

  // Get current conversation messages
  const messages = useHistoryStore((s) =>
    Object.values(s.primitives.getCurrentConversation().history)
  );

  // Get specific state
  const currentConversationId = useHistoryStore((s) => s.currentConversation);

  return (
    <div>
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      <button onClick={() => sendMessage("Hello!", ragbitsClient)}>
        Send
      </button>
    </div>
  );
}

Plugin System#

The UI supports a powerful plugin architecture for extending functionality. Plugins can inject components into predefined UI slots, add routes, wrap existing routes, and run lifecycle hooks.

Plugin Architecture Overview#

┌─────────────────────────────────────────────────────────────┐
│                     Plugin Registration                      │
│         pluginManager.register(myPlugin)                     │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                  Plugin Activation                           │
│         pluginManager.activate(pluginName)                   │
└──────────────────────┬──────────────────────────────────────┘
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
  ┌─────────────┐ ┌────────────┐ ┌──────────────┐
  │ Register    │ │ Inject     │ │ Call         │
  │ Slots       │ │ Routes     │ │ onActivate   │
  └─────────────┘ └────────────┘ └──────────────┘

Creating a Plugin#

Use the createPlugin helper function to define a type-safe plugin:

// src/plugins/MyPlugin/index.tsx
import { lazy } from "react";
import { createPlugin } from "../../core/utils/plugins/utils";

// Lazy-load components for code splitting
const MyButton = lazy(() => import("./components/MyButton"));
const MyPanel = lazy(() => import("./components/MyPanel"));

export const MyPluginName = "MyPlugin";

export const MyPlugin = createPlugin({
  name: MyPluginName,

  // Components available for use with PluginWrapper
  components: {
    MyButton,
    MyPanel,
  },

  // Lifecycle hooks
  onActivate: () => {
    console.log("MyPlugin activated");
  },
  onDeactivate: () => {
    console.log("MyPlugin deactivated");
  },

  // UI slot attachments
  slots: [
    {
      slot: "layout.headerActions",
      component: MyButton,
      priority: 5, // Higher priority = rendered first
      condition: () => true, // Optional: dynamic visibility
    },
  ],

  // Route definitions
  routes: [
    {
      path: "/my-feature",
      element: <MyFeatureRoute />,
    },
  ],

  // Route wrappers
  routeWrappers: [
    {
      target: "/", // Or "global" for all routes
      wrapper: (children) => <MyWrapper>{children}</MyWrapper>,
    },
  ],

  // Custom metadata
  metadata: {
    version: "1.0.0",
    author: "Your Name",
  },
});

Plugin Interface#

interface Plugin<T> {
  name: string;
  components: T; // Record of lazy-loaded components
  onActivate?: () => void;
  onDeactivate?: () => void;
  routes?: PluginRoute[];
  routeWrappers?: PluginRouteWrapper[];
  slots?: PluginSlot[];
  metadata?: Record<string, unknown>;
}

Defining and Using Slots#

Slots are predefined UI extension points where plugins can inject components.

Available Slots#

Slot Name Location Props
layout.sidebar Left sidebar area None
layout.headerActions Header action buttons None
message.actions Per-message action buttons { message, content, serverId }
prompt.beforeSend Before the prompt input None

Attaching to Slots#

// In your plugin definition
slots: [
  {
    slot: "message.actions",
    component: FeedbackButton,
    priority: 10, // Higher = rendered first
    condition: () => isFeatureEnabled(), // Optional
  },
];

Creating Slot Components#

Components attached to slots receive props based on the slot type:

// For message.actions slot
interface MessageActionsProps {
  message: ChatMessage;
  content: string;
  serverId?: string;
}

const FeedbackButton: FC<MessageActionsProps> = ({ message, content }) => {
  return <Button onClick={() => submitFeedback(message.id)}>Feedback</Button>;
};

Rendering Slots in Components#

Use the <Slot> component to render plugin content:

import { Slot } from "../core/components/Slot";

function MessageActions({ message, content }: Props) {
  return (
    <div className="flex gap-2">
      <Slot
        name="message.actions"
        props={{ message, content }}
        fallback={<span>No actions</span>}
        skeletonSize={{ width: "32px", height: "32px" }}
      />
    </div>
  );
}

Checking if Slot Has Content#

import { useSlotHasFillers } from "../core/utils/slots/useSlotHasFillers";

function Sidebar() {
  const hasSidebarContent = useSlotHasFillers("layout.sidebar");

  if (!hasSidebarContent) {
    return null;
  }

  return (
    <aside>
      <Slot name="layout.sidebar" />
    </aside>
  );
}

Using PluginWrapper#

PluginWrapper provides type-safe rendering of plugin components with automatic lazy loading and skeleton fallbacks:

import { PluginWrapper } from "../core/utils/plugins/PluginWrapper";
import { MyPlugin } from "../plugins/MyPlugin";

function SomeComponent() {
  return (
    <PluginWrapper
      plugin={MyPlugin}
      component="MyButton"
      componentProps={{ onClick: handleClick }}
      skeletonSize={{ width: "100px", height: "40px" }}
    />
  );
}

PluginWrapper Props#

Prop Type Description
plugin Plugin The plugin instance
component keyof plugin.components Component name to render
componentProps ComponentProps Props to pass to the component
skeletonSize { width, height } Skeleton size during loading
disableSkeleton boolean Disable loading skeleton

Route Definitions#

Plugins can add new routes or inject routes into existing route trees.

Adding Top-Level Routes#

routes: [
  {
    path: "/login",
    element: <LoginPage />,
  },
]

Injecting Nested Routes#

routes: [
  {
    target: "/", // Parent route to inject into
    path: "conversation/:conversationId",
    element: <ConversationPage />,
  },
]

Route Wrappers#

Wrap existing routes with HOCs for authentication, guards, etc:

routeWrappers: [
  // Wrap a specific route
  {
    target: "/",
    wrapper: (children) => <AuthGuard>{children}</AuthGuard>,
  },
  // Wrap all routes globally
  {
    target: "global",
    wrapper: (children) => <ErrorBoundary>{children}</ErrorBoundary>,
  },
]

Registering and Activating Plugins#

Static Registration (in main.tsx)#

import { pluginManager } from "./core/utils/plugins/PluginManager";
import { MyPlugin } from "./plugins/MyPlugin";

// Register plugins before rendering
pluginManager.register(MyPlugin);

// Activate immediately or conditionally
pluginManager.activate(MyPlugin.name);

Dynamic Activation (based on config)#

// In a component or hook
import { pluginManager } from "./core/utils/plugins/PluginManager";

function usePluginActivation() {
  const { config } = useConfigContext();

  useEffect(() => {
    if (config.myFeatureEnabled) {
      pluginManager.activate(MyPluginName);
    }
  }, [config]);
}

Dynamic Plugin Creation#

// Factory function for dynamic plugins
export const createOAuth2LoginPlugin = (
  provider: string,
  displayName: string,
) => {
  return createPlugin({
    name: `OAuth2Login_${provider}`,
    components: {
      OAuth2Login: lazy(() => import("./components/OAuth2Login")),
    },
    metadata: { provider, displayName },
  });
};

// Usage
const googlePlugin = createOAuth2LoginPlugin("google", "Google");
pluginManager.register(googlePlugin);
pluginManager.activate(googlePlugin.name);

React Hooks for Plugins#

// Get a specific plugin
import { usePlugin } from "./core/utils/plugins/usePlugin";
const myPlugin = usePlugin("MyPlugin");

// Get all active plugins
import { useActivePlugins } from "./core/utils/plugins/useActivePlugins";
const activePlugins = useActivePlugins();

// Check if slot has content
import { useSlotHasFillers } from "./core/utils/slots/useSlotHasFillers";
const hasHeaderActions = useSlotHasFillers("layout.headerActions");

Example Plugin Structure#

plugins/
└── MyPlugin/
    ├── index.tsx          # Plugin entry point with createPlugin
    ├── components/
    │   ├── MyButton.tsx   # Lazy-loaded component
    │   └── MyPanel.tsx    # Lazy-loaded component
    └── types.ts           # Plugin-specific types

What Works in Standalone Mode#

  • Chat input and message display
  • Theme switching (light/dark)
  • Basic conversation flow
  • Custom store implementations
  • Core UI components

What Requires Additional Setup#

  • Plugins: Need explicit registration and activation
  • Conversation persistence: Requires custom store with IndexedDB
  • Config-based customization: Requires ConfigContextProvider
  • Authentication: Requires AuthPlugin activation