From 6e82aadbbc746ec0f64bd65a45503fa8ffa806b2 Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Thu, 20 Feb 2025 15:29:53 -0800 Subject: [PATCH] merge --- src/react_agent/app.py | 252 +++++++++++++++++++++++++++++++++-------- 1 file changed, 203 insertions(+), 49 deletions(-) diff --git a/src/react_agent/app.py b/src/react_agent/app.py index c1fe22e..0f24459 100644 --- a/src/react_agent/app.py +++ b/src/react_agent/app.py @@ -1,22 +1,14 @@ -from fasthtml.common import ( - Button, - Div, - FastHTML, - Form, - Group, - Hidden, - Input, - Link, - Script, - Titled, - picolink, -) +from fasthtml.common import * +import uuid from langgraph_sdk import get_client +from starlette.responses import StreamingResponse +from datetime import datetime +from typing import TypedDict, Dict, List -# None means point to the local run loop +# Initialize the LangGraph client langgraph_client = get_client() -# Set up the app, including daisyui and tailwind for the chat component +# Define HTML headers for styling and client-side functionality hdrs = ( picolink, Script(src="https://cdn.tailwindcss.com"), @@ -24,25 +16,55 @@ hdrs = ( rel="stylesheet", href="https://cdn.jsdelivr.net/npm/daisyui@4.11.1/dist/full.min.css", ), + Script(src="https://unpkg.com/htmx.org@1.9.2/dist/htmx.min.js"), + Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"), +) + +# Create the FastHTML app with headers and default styling +app = FastHTML( + hdrs=hdrs, ct_hdr=True, cls="flex flex-row h-screen", live=True, debug=True ) -app = FastHTML(hdrs=hdrs, cls="p-4 max-w-lg mx-auto") -# Chat message component (renders a chat bubble) -def ChatMessage(msg, user): - bubble_class = "chat-bubble-primary" if user else "chat-bubble-secondary" - chat_class = "chat-end" if user else "chat-start" - return Div(cls=f"chat {chat_class}")( - Div("user" if user else "assistant", cls="chat-header"), - Div(msg, cls=f"chat-bubble {bubble_class}"), - Hidden(msg, name="messages"), +# Define Thread type for clarity (though not strictly necessary in Python) +class Thread(TypedDict): + thread_id: str + created_at: datetime + updated_at: datetime + metadata: dict + status: str + values: dict + interrupts: Dict[str, List[dict]] + + +# Function to get or set user_id via cookie +def get_user_id(request): + user_id = request.cookies.get("user_id") + if not user_id: + user_id = str(uuid.uuid4()) + return user_id + + +# Function to render a chat message +def ChatMessage(msg, idx): + bubble_class = ( + "chat-bubble-primary" if msg["type"] == "human" else "chat-bubble-secondary" + ) + chat_class = "chat-end" if msg["type"] == "human" else "chat-start" + return Div( + Div(msg["type"], cls="chat-header"), + Div( + msg["content"], id=f"chat-content-{idx}", cls=f"chat-bubble {bubble_class}" + ), + id=f"chat-message-{idx}", + cls=f"chat {chat_class}", ) -# The input field for the user message. Also used to clear the -# input field after sending a message via an OOB swap +# Function to create the chat input field def ChatInput(): return Input( + type="text", name="msg", id="msg-input", placeholder="Type a message", @@ -51,33 +73,165 @@ def ChatInput(): ) -# The main screen -@app.get("/") -def index(): - page = Form(hx_post=send, hx_target="#chatlist", hx_swap="beforeend")( - Div(id="chatlist", cls="chat-box h-[73vh] overflow-y-auto"), - Div(cls="flex space-x-2 mt-2")( - Group(ChatInput(), Button("Send", cls="btn btn-primary")) +# Sidebar with conversation list +async def ConversationList(user_id: str, current_thread_id: str): + threads = await langgraph_client.threads.search( + metadata={"user_id": user_id}, limit=50, offset=0 + ) + return Div( + H2("Conversations", cls="text-lg font-bold mb-2"), + Div( + *[ + A( + f"Thread {i+1} ({thread['created_at']})", + href=f"/conversations/{thread['thread_id']}", + cls="block p-2 hover:bg-gray-200 rounded" + + ( + " bg-gray-300" + if thread["thread_id"] == current_thread_id + else "" + ), + ) + for i, thread in enumerate(threads) + ], + cls="overflow-y-auto h-[calc(100vh-4rem)]", ), + cls="w-1/4 bg-gray-100 p-4 border-r", ) - return Titled("Chatbot Demo", page) -# Handle the form submission -@app.post("/send") -async def send(msg: str, messages: list[str] = None): - if not messages: +# Root route: Redirect to a new conversation +@app.route("/") +def get(request): + user_id = get_user_id(request) + thread_id = str(uuid.uuid4()) + return Redirect( + f"/conversations/{thread_id}", + cookies=[("user_id", user_id, {"httponly": True, "max_age": 31536000})], + ) + + +# Conversation route: Display the chat interface with sidebar +@app.route("/conversations/{thread_id}") +async def get(thread_id: str, request): + user_id = get_user_id(request) + + # Create thread with user_id in metadata + await langgraph_client.threads.create( + thread_id=thread_id, if_exists="do_nothing", metadata={"user_id": user_id} + ) + + # Fetch thread state + try: + state = await langgraph_client.threads.get_state(thread_id) + messages = state["values"]["messages"] + except Exception: messages = [] - messages.append(msg.rstrip()) - result = await langgraph_client.runs.wait( - None, - "agent", - input={"messages": messages}, - ) - response = result["messages"][-1]["content"] - return ( - ChatMessage(msg, True), # The user's message - ChatMessage(response.strip(), False), # The chatbot's response - ChatInput(), + # New Thread button + new_thread_button = Button( + "New Thread", + cls="btn btn-secondary btn-sm mr-2", + hx_get="/new-thread", + hx_swap="none", ) + + # Main chat content + chat_content = Div( + Div( + Titled("Chatbot Demo", ""), + new_thread_button, + cls="flex justify-between items-center mb-4", + ), + Div( + *[ChatMessage(msg, i) for i, msg in enumerate(messages)], + id="chatlist", + cls="chat-box h-[calc(100vh-8rem)] overflow-y-auto", + ), + Form( + Group(ChatInput(), Button("Send", cls="btn btn-primary")), + hx_post=f"/conversations/{thread_id}/send-message", + hx_target="#chatlist", + hx_swap="beforeend", + cls="flex space-x-2 mt-2", + ), + cls="flex-1 p-4", + ) + + # Full page with sidebar and chat + page = Div( + await ConversationList(user_id, thread_id), + chat_content, + cls="flex w-full h-full", + ) + + return Titled( + "Chatbot Demo", + page, + cookies=[("user_id", user_id, {"httponly": True, "max_age": 31536000})], + ) + + +# Route for creating a new thread +@app.get("/new-thread") +def new_thread(request): + thread_id = str(uuid.uuid4()) + return Redirect(f"/conversations/{thread_id}") + + +# Function to create an assistant message placeholder with SSE +def AssistantMessagePlaceholder(thread_id, msg): + content_id = f"assistant-content-{uuid.uuid4()}" + return Div( + Div("", id=content_id, cls="chat-bubble chat-bubble-secondary"), + cls="chat chat-start", + hx_ext="sse", + sse_connect=f"/conversations/{thread_id}/get-message?msg={msg}", + sse_swap="message", + hx_target=f"#{content_id}", + hx_swap="beforeend", + ) + + +# Route to handle sending a new message +@app.post("/conversations/{thread_id}/send-message") +def send_message(thread_id: str, msg: str): + user_msg_div = ChatMessage( + {"type": "human", "content": msg}, f"user-{uuid.uuid4()}" + ) + assistant_placeholder = AssistantMessagePlaceholder(thread_id, msg) + return user_msg_div, assistant_placeholder, ChatInput() + + +# Generator for streaming assistant responses +async def message_generator(thread_id: str, msg: str): + async for event in langgraph_client.runs.stream( + thread_id, + "agent", + input={"messages": [{"type": "human", "content": msg}]}, + stream_mode="messages-tuple", + multitask_strategy="interrupt", + ): + if event.event == "messages": + for chunk_msg in event.data: + chunk = chunk_msg.get("content", "") + if chunk.strip(): + yield f"event: message\ndata: {chunk}\n\n" + yield "event: close\ndata:\n\n" + + +# Route to stream assistant responses via SSE +@app.get("/conversations/{thread_id}/get-message") +async def get_message(thread_id: str, msg: str): + return StreamingResponse( + message_generator(thread_id, msg), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +# Run the app if executed directly +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000)