Build a Chat App with Remix Hooks

Build a Chat App with Remix Hooks
user avatar

Dustin W. Carr

April 26, 2024

Summary:

A tutorial on building an advanced chat application in Remix using hooks and the useFetcher API to enable fetching messages from within a component.

Remix

Hooks

Chatbot

Building a Chat Application with Remix Using Hooks and `useFetcher`

I decided to extend the latest Remix Chat Application tutorial by adding a new feature: the ability to fetch messages from an API within a component using useFetcher. This is fairly identical to the previous simple chat application, but it can be easily transported within your application without any additional setup.

Prerequisites

You can clone the repo at GitHub.

From there you just need to run npm install and npm run dev to start the server and navigate to http://localhost:3000 to see the chat application in action.

We use similar components to power our site at https://darkviolet.ai and https://chatter.darkviolet.ai if you want to see a live example.

Implementing the Chat Hooks

Before we can fetch messages with useFetcher, we need to create resource routes. In this project, they can be found in the app/routes/api+ directory. We are creating two routes. One of these portal-chat.$portalName.ts will be for creating a chat bots, similar to the first tutorial, and the other, single-text-gen.ts will be for handling a single text generation. The key pieces of these are found in the loaders and actions.

The previous tutorials have covered the code, so I refer you to those to understand the flow. I don't see any need to repeat that here.

The new elements here are the hooks that are used for interacting with these resource routes. Let's look at the code for the usePortalChat hook:

import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
import { ChatLoaderData } from "~/lib/loaders/portalChatLoader";

export function usePortalChat({
  portalName,  
  systemPrompt,
  getInitialAiMessage = true,
}: {
  portalName: string;
  systemPrompt: string;
  getInitialAiMessage?: Boolean; 
}) {
  const chatFetcher = useFetcher<ChatLoaderData>();
  const chatSubmitter = useFetcher();
  const [shouldRevalidate, setShouldRevalidate] = useState(false);

  useEffect(() => {
    console.log("getting chat messages");
    if (
      (shouldRevalidate || !chatFetcher.data) &&
      chatFetcher.state === "idle"
    ) {
      chatFetcher.load(`/api/portal-chat/${portalName}`);
      setShouldRevalidate(false);
    }
  }, [
    chatFetcher,
    shouldRevalidate,
    portalName,
    chatFetcher.data,
    chatFetcher.state,
  ]);

  useEffect(() => {
    if (
      getInitialAiMessage &&
      chatFetcher.state === "idle" &&
      chatSubmitter.state === "idle" &&
      !chatSubmitter.data &&
      chatFetcher.data &&
      chatFetcher.data.messages.length === 0
    ) {
      console.log("submitting initial chat message");
      chatSubmitter.submit(
        { system: systemPrompt, chatInput: "" },
        {
          action: `/api/portal-chat/${portalName}`,
          method: "POST",
        }
      );
    }
  }, [
    chatFetcher.data,
    chatFetcher.state,
    chatSubmitter.state,
    chatSubmitter.data,
    portalName,
  ]);

  useEffect(() => {
    if (chatSubmitter.data) {
      setShouldRevalidate(true);
    }
  }, [chatSubmitter.data]);

  const submitChatMessage = async (message: string) => {
    chatSubmitter.submit(
      { system: systemPrompt, chatInput: message },
      {
        action: `/api/portal-chat/${portalName}`,
        method: "POST",
      }
    );
  };

  const clearChat = async () => {
    chatFetcher.load(`/api/portal-chat/${portalName}?clearHistory=true`);
  };
  const isLoading =
    chatFetcher.state === "loading" || chatSubmitter.state === "submitting";

  return {
    isLoading,
    submitChatMessage,
    clearChat,
    messages: chatFetcher.data?.messages || [],
  };
}

In order to implement a conversational chat, we will use two fetchers. The first fetcher, chatFetcher, is used to get the chat messages from the server. The second fetcher, chatSubmitter, is used to send the chat messages to the server. The usePortalChat hook is responsible for managing these fetchers and the chat messages. Separating things in this manner facilitates smooth flow of messages between the client and the server.

The hook returns a function submitChatMessage that is used to send the chat messages to the server. The function clearChat is used to clear the chat history. The isLoading variable is used to show a loading spinner when the chat messages are being fetched or sent to the server. The messages variable contains the full history of chat messages in this conversation.

When we submit the chat message, the action at the resource route will send the message to the LLM and return the response when it is ready. When we receive this response, we then revalidate the loader, which retrieves the entire history. While this does mean more data is retrieved from the server, it ensures that the client and server are always in sync, and prevents us from having to manage the state of the conversation on the client.

The implementation of the single text gen hook is much simpler. In this case, we aren't trying to manage the history, and so we can just use the returned result from the action. Here is the code for the useSingleTextGen hook:

import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
import { ChatLoaderData } from "~/loaders/portalChatLoader";

export function useSingleTextGen({ systemPrompt }: { systemPrompt: string }) {
  const chatSubmitter = useFetcher<{ response: string }>();

  const submitChatMessage = async (message: string) => {
    chatSubmitter.submit(
      { system: systemPrompt, userPrompt: message },
      {
        action: `/api/single-text-gen`,
        method: "POST",
      }
    );
  };

  const isLoading = chatSubmitter.state === "submitting";

  return {
    isLoading,
    submitChatMessage,
    response: chatSubmitter.data?.response,
  };
}

Using the Chat Hook

To use the chat hook, we build a component, which is basically the same as our previous chat component, but instead of passing the chats to it, we pass the portal name and system prompt, and then we handle the submit action from the chat input form. Here is the relevant code for implementing this feature.

export default function BaseChatComponent({
  buttonClassName = "",
  aiBubbleClassName = "",
  userBubbleClassName = "",
  aiChatbotName = "AI Chatbot",
  iconClassName = "",
  portalName,
  systemPrompt,
}: {
  buttonClassName?: string;
  aiBubbleClassName?: string;
  userBubbleClassName?: string;
  aiChatbotName?: string;
  iconClassName?: string;
  portalName: string;
  systemPrompt: string;
}) {
  const [searchParams, setSearchParams] = useSearchParams();
  const formRef = useRef<HTMLFormElement>(null);
  const chatContainerRef = useRef<HTMLDivElement>(null);

  const { isLoading, submitChatMessage, clearChat, messages } = usePortalChat({
    portalName: portalName,
    systemPrompt: systemPrompt,
  });
  console.log("messages", messages);
  
  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const message = formData.get("chatInput") as string;
    submitChatMessage(message);
  };

  useEffect(() => {
    if (!isLoading) {
      formRef.current?.reset();
    }
  }, [isLoading]);

  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submitChatMessage(event.currentTarget.value);
    }
  };

  useEffect(() => {
    if (chatContainerRef.current !== null) {
      const { scrollHeight, clientHeight, scrollTop } =
        chatContainerRef.current;
      console.log(scrollHeight, clientHeight, scrollTop);
      const maxScrollTop = scrollHeight - clientHeight;
      chatContainerRef.current.scrollTop = maxScrollTop;
    }
  }, [messages.length]);

  return ( ...

We then implement this in the route as follows:

import { useParams } from "@remix-run/react";
import BasechatComponent from "./components/baseChatComponent";
import Flex from "~/components/buildingBlocks/flex";

const chatPortals = {
  chatbot: {
    systemPrompt: "You are a helpful assistant?  Be cool.",
    portalName: "chatbot",
  },
  lonelygirl23: {
    systemPrompt:
      "You are a lonely girl in a lonely world.  
      Being helpful gives you a sense of purpose.  
      Being praised brings you the greatest joy.",   
    portalName: "lonelygirl23",
  },
};

export default function Chat() {
  const { portalName } = useParams() as {
    portalName: keyof typeof chatPortals;
  };
  const { systemPrompt } = chatPortals[portalName];

  return (
    <Flex className="w-[45vw] hidden xl:flex items-center">
      <BasechatComponent portalName={portalName} systemPrompt={systemPrompt} />
    </Flex>
  );
}

As you see, this lets you easily set up multiple chat portals with different system prompts. This is a simple example, but it can be easily extended to more complex chat applications.

Run the app and navigate to http://localhost:3000/chat/with-portal/chatbot to see the chat application in action.

Conclusion

In this tutorial, we have extended the previous chat application by adding the ability to fetch messages from an API within a component using useFetcher. This is a simple example, but it can be easily extended to more complex chat applications. I hope you find this tutorial helpful.

If you have any questions, or if you want help in your implementation -- including security, user management, document preparation, and multi-modal implementations -- you are welcome to contact us through Dark Violet.

If you find this useful, make sure to STAR our repository on GitHub. Thank You!

© 2024 DarkViolet.ai All Rights Reserved