Build a Joke-Telling Chatbot with LangGraph and Streamlit

Let’s explore how to build an interactive joke-telling chatbot using LangGraph, LangChain, and Streamlit. Our chatbot specializes in telling jokes by maintaining a conversational flow: it presents a setup and waits for the user’s response before delivering the punchline. Though small, this app provides a good example of some basic prompt engineering, LLM tool usage, state management, and a token streaming web interface.

Architecture Overview

The application consists of two main components:

  1. A joke-telling agent built with LangGraph
  2. A web interface built with Streamlit

The Joke-Telling Agent

Let’s take a look at the joke-telling agent, implemented using LangGraph and LangChain.

import dotenv

from langchain_openai import ChatOpenAI

from langchain.agents import tool
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.schema import SystemMessage
from langchain_core.messages import trim_messages

from langgraph.graph import MessagesState, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt import ToolNode, tools_condition

# Load api keys from .env file.
dotenv.load_dotenv()

# Extra fields in state could be added here.
class State(MessagesState):
    pass

# Set up the finest jokes of all time.
@tool
def get_joke_setup():
    """Get some joke setups"""
    return ["Why is a pirate's favorite letter 'R'?", "What do you call a fish without eyes?"]

@tool
def get_joke_punchline():
    """Get some joke punchlines"""
    return ["Because, if you think about it, 'R' is the only letter that makes sense.",
            "Its a fsh!"]

tools = [get_joke_setup, get_joke_punchline]

def make_agent():
    model = ChatOpenAI(model_name="gpt-4o-mini", temperature=0, streaming=True)
    # Limit the size of the context window provided to the LLM.
    trimmer = trim_messages(
        max_tokens=10000,
        strategy="last",
        token_counter=model,
        include_system=True,
        allow_partial=False,
        start_on="human")
    prompt_template = ChatPromptTemplate.from_messages([
        SystemMessage(content="""
            You are a expert at telling jokes.
            You always start with the setup, and wait for the user to respond before telling the punch line.
            Prefer using tools to get the joke setup and punchline.
            Example:
            user: tell me a joke!
            ai: why did the chicken cross the road?
            user: why?
            ai: to get to the other side!
            """),
        MessagesPlaceholder(variable_name="messages")])

    chain = trimmer | prompt_template | model.bind_tools(tools)

    def agent(state: State):
        print("---- jokes agent ----")
        response = chain.invoke(state["messages"])
        print("---- jokes agent finished ----")
        return {"messages": response}

    return agent

# A basic tool using agent setup.
# This setup is almost identical to LangGraph's create_react_agent function.
graph = StateGraph(state_schema=State) \
    .add_node("agent", make_agent()) \
    .add_node("tools", ToolNode(tools)) \
    .set_entry_point("agent") \
    .add_conditional_edges("agent", tools_condition) \
    .add_edge("tools", "agent") \
    .compile(checkpointer=InMemorySaver())

The agent is designed with two main tools:

  • get_joke_setup: Provides joke setups
  • get_joke_punchline: Provides corresponding punchlines

We use LangGraph’s StateGraph to manage the conversation flow and LangChain’s tools to handle joke retrieval. The agent is configured with a system prompt that instructs it to:

  1. Always start with a setup
  2. Wait for user response
  3. Deliver the punchline

The Web Interface

The web interface is built using Streamlit, providing a clean and interactive chat experience.

import streamlit as st

from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
from joke_agent import graph, State

load_dotenv()

# Initialize session state
if "messages" not in st.session_state:
    st.session_state.messages = []

st.title("Chatbot")

# Display the chat history.
for message in st.session_state.messages:
    if isinstance(message, HumanMessage):
        with st.chat_message("user"):
            st.write(message.content)
    elif isinstance(message, AIMessage):
        with st.chat_message("assistant"):
            st.write(message.content)

# Handle user input.
if prompt := st.chat_input("What's on your mind?"):
    # Add user message to chat history
    user_message = HumanMessage(content=prompt)
    st.session_state.messages.append(user_message)
    
    # Display user message
    with st.chat_message("user"):
        st.write(prompt)

    # Create a placeholder for the streaming response
    with st.chat_message("assistant"):
        message_placeholder = st.empty()
        full_response = ""
        
        try:
            state = State(messages=[user_message])
            config = {"configurable": {"thread_id": "thread"}}

            # invoke the agent, streaming tokens from any llm calls directly
            for chunk, metadata in graph.stream(state, config=config, stream_mode="messages"):
                if isinstance(chunk, AIMessage):
                    full_response = full_response + chunk.content
                    message_placeholder.markdown(full_response + "▌")

                elif isinstance(chunk, ToolMessage):
                    full_response = full_response + f"🛠️ Used tool to get: {chunk.content}\n\n"
                    message_placeholder.markdown(full_response + "▌")

            # Once streaming is complete, display the final message without the cursor
            message_placeholder.markdown(full_response)

            # Add the complete message to session state
            st.session_state.messages.append(AIMessage(content=full_response))
            
        except Exception as e:
            st.error(f"An error occurred: {str(e)}") 

Key features of the interface include:

  • Persistent chat history using Streamlit’s session state
  • Real-time message streaming
  • Clear distinction between user and assistant messages
  • Tool usage visibility with emoji indicators

How It Works

  1. When a user sends a message, it’s added to the UI’s chat history
  2. The UI invokes the agent graph with the newly added user message
  3. LangGraph merges the user message with the existing conversation history (stored in memory in using the InMemorySaver)
  4. Responses are streamed in real-time with a typing indicator
  5. Tool usage is visibly indicated with a 🛠️ emoji

Dependencies

This example is built with Pipenv using the following Pipfile:

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
streamlit = "*"
langgraph = "*"
langchain = "*"
langchain-openai = "*"
python-dotenv = "*"

[dev-packages]

[requires]
python_version = "3.11"

[scripts]
start = "streamlit run chatbot.py --server.headless true" 

Running the Application

You can start the application using:

pipenv run start

Conclusion

This implementation demonstrates how to combine LangGraph’s powerful state management with Streamlit’s user-friendly interface to create an engaging chatbot. The architecture allows for easy extension with additional tools and capabilities while maintaining a clean separation of concerns between the agent logic and the user interface.

The use of LangGraph’s streaming capabilities ensures a responsive user experience, while the tool-based approach makes it easy to extend the bot’s capabilities with new jokes or other features.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *