From ada323fe9f6669706128cba767c8f43fff781953 Mon Sep 17 00:00:00 2001 From: Nam Le Date: Wed, 18 Jun 2025 13:15:18 +0200 Subject: [PATCH] Make frontend for chatbot app Make frontend using Nextjs to interact with chatbot. Allows creating new conversation, list all conversations, submit chat message, read AI response, show AI request for human feedback and submit human feedback Refactor FastAPI app. Handle LangGraph interrupt message: get correct data field in backend to show it in front end. --- .gitignore | 3 +- api/main.py | 101 +- api/models.py | 48 + api/routers.py | 98 + src/chatbot/__about__.py | 2 +- ui/.gitignore | 41 + ui/README.md | 36 + ui/app/components/HumanReviewForm.tsx | 58 + ui/app/components/MessageInput.tsx | 30 + ui/app/components/ResponseDisplay.tsx | 16 + ui/app/components/ThreadList.tsx | 48 + ui/app/favicon.ico | Bin 0 -> 25931 bytes ui/app/globals.css | 26 + ui/app/layout.tsx | 34 + ui/app/page.tsx | 158 + ui/app/utils/chat.tsx | 47 + ui/eslint.config.mjs | 16 + ui/next.config.ts | 7 + ui/package-lock.json | 6193 +++++++++++++++++++++++++ ui/package.json | 28 + ui/postcss.config.mjs | 5 + ui/tsconfig.json | 27 + 22 files changed, 6933 insertions(+), 89 deletions(-) create mode 100644 api/models.py create mode 100644 api/routers.py create mode 100644 ui/.gitignore create mode 100644 ui/README.md create mode 100644 ui/app/components/HumanReviewForm.tsx create mode 100644 ui/app/components/MessageInput.tsx create mode 100644 ui/app/components/ResponseDisplay.tsx create mode 100644 ui/app/components/ThreadList.tsx create mode 100644 ui/app/favicon.ico create mode 100644 ui/app/globals.css create mode 100644 ui/app/layout.tsx create mode 100644 ui/app/page.tsx create mode 100644 ui/app/utils/chat.tsx create mode 100644 ui/eslint.config.mjs create mode 100644 ui/next.config.ts create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/postcss.config.mjs create mode 100644 ui/tsconfig.json diff --git a/.gitignore b/.gitignore index e6d3854..66df350 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ .pytest_cache .ruff_cache .langgraph_api -dist \ No newline at end of file +dist +ui/node_modules \ No newline at end of file diff --git a/api/main.py b/api/main.py index e20e204..6c451a0 100644 --- a/api/main.py +++ b/api/main.py @@ -1,93 +1,20 @@ from fastapi import FastAPI -from langgraph_sdk import get_client -from pydantic import BaseModel - -app = FastAPI() - -langgraph_client = get_client(url="http://langgraph-api:8000") - - -class ChatMessage(BaseModel): - text: str - model_config = { - "json_schema_extra": { - "examples": [ - { - "text": "Hello", - } - ] - } - } - - -class HumanReview(BaseModel): - action: str - data: str - model_config = { - "json_schema_extra": { - "examples": [ - { - "action": "feedback", - "data": "That's not what I meant! Please try again.", - }, - { - "action": "continue", - "data": "", - }, - ] - } - } +from fastapi.middleware.cors import CORSMiddleware +from .routers import router -@app.get("/") -async def read_main(): - return {"msg": "Hello! Welcome to the LangGraph Chat API"} - - -@app.get("/chat") -async def list_chat_threads(): - """ - List all chat threads. - """ - return await langgraph_client.threads.search( - metadata={"graph_id": "chat"}, - ) - - -@app.get("/chat/{thread_id}") -async def get_chat_history(thread_id: str): - """ - Get the chat history for the given thread. - """ - return await langgraph_client.threads.get(thread_id=thread_id) - +app = FastAPI() -@app.post("/chat/{thread_id}") -async def chat_with_thread(thread_id: str, message: ChatMessage): - """ - Take message from user and return response from chatbot. - If chat thread does not exist, create a new thread. - """ - return await langgraph_client.runs.wait( - thread_id=thread_id, - assistant_id="chat", - input={"messages": [{"role": "user", "content": message.text}]}, - if_not_exists="create", - ) +origins = [ + "http://localhost:3000", +] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) -@app.post("/chat/{thread_id}/human_review") -async def human_review(thread_id: str, review: HumanReview): - """ - Submit human review to resume interrupted thread. - """ - return await langgraph_client.runs.wait( - thread_id=thread_id, - assistant_id="chat", - command={ - "resume": { - "action": review.action, - "data": review.data, - } - }, - ) +app.include_router(router) diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..90209c7 --- /dev/null +++ b/api/models.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel + + +class ChatMessage(BaseModel): + text: str + model_config = { + "json_schema_extra": { + "examples": [ + { + "text": "Hello", + } + ] + } + } + + +class HumanReview(BaseModel): + action: str + data: str + model_config = { + "json_schema_extra": { + "examples": [ + { + "action": "feedback", + "data": "That's not what I meant! Please try again.", + }, + { + "action": "continue", + "data": "", + }, + ] + } + } + + +class Thread(BaseModel): + thread_id: str + status: str + model_config = { + "json_schema_extra": { + "examples": [ + { + "thread_id": "ffbbe00c-c65d-437e-892d-a4b59120e3c9", + "status": "idle", + }, + ] + } + } diff --git a/api/routers.py b/api/routers.py new file mode 100644 index 0000000..2e0d9a0 --- /dev/null +++ b/api/routers.py @@ -0,0 +1,98 @@ +from typing import Any + +from fastapi import APIRouter +from langgraph_sdk import get_client + +from .models import ChatMessage, HumanReview, Thread + +router = APIRouter() +langgraph_client = get_client(url="http://langgraph-api:8000") + + +def parse_ai_response( + ai_response: list[dict] | dict[str, Any], +) -> dict[str, Any]: + if ( + not isinstance(ai_response, dict) + or not {"__interrupt__", "messages"} & ai_response.keys() + ): + raise ValueError( + "Unexpected response format from LangGraph API. Expected a dictionary with key 'messages' or '__interrupt__'." + ) + if "__interrupt__" in ai_response: + return { + "type": "interrupt", + "data": ai_response["__interrupt__"][-1]["value"]["tool_call"]["args"][ + "query" + ], + } + return {"type": "message", "data": ai_response["messages"][-1]["content"]} + + +@router.get("/") +async def read_main() -> dict: + return {"msg": "Hello! Welcome to the LangGraph Chat API"} + + +@router.get("/chat") +async def list_chat_threads() -> list[Thread]: + threads_data = await langgraph_client.threads.search( + metadata={"graph_id": "chat"}, + ) + return [ + Thread(thread_id=thread["thread_id"], status=thread["status"]) + for thread in threads_data + ] + + +@router.get("/chat/{thread_id}") +async def get_chat_history(thread_id: str) -> dict[str, list[str]]: + thread_data = await langgraph_client.threads.get(thread_id=thread_id) + if not isinstance(thread_data["values"], dict): + raise ValueError( + "Unexpected response format from LangGraph API. Expected a dictionary with key 'values'." + ) + return { + "history": [ + message["content"] + for message in thread_data["values"]["messages"] + if message["type"] in ["human", "ai"] and message["content"] != "" + ] + } + + +@router.get("/chat/{thread_id}/debug") +async def get_chat_history_debug(thread_id: str): + return await langgraph_client.threads.get(thread_id=thread_id) + + +@router.post("/chat/{thread_id}") +async def chat_with_thread(thread_id: str, message: ChatMessage) -> dict[str, Any]: + ai_response = await langgraph_client.runs.wait( + thread_id=thread_id, + assistant_id="chat", + input={"messages": [{"role": "user", "content": message.text}]}, + if_not_exists="create", + ) + return parse_ai_response(ai_response) + + +@router.post("/chat/{thread_id}/human_review") +async def human_review(thread_id: str, review: HumanReview) -> dict[str, Any]: + ai_response = await langgraph_client.runs.wait( + thread_id=thread_id, + assistant_id="chat", + command={ + "resume": { + "action": review.action, + "data": review.data, + } + }, + ) + return parse_ai_response(ai_response) + + +@router.delete("/chat/{thread_id}") +async def delete_chat_thread(thread_id: str) -> dict[str, Any]: + await langgraph_client.threads.delete(thread_id=thread_id) + return {"success": True, "thread_id": thread_id} diff --git a/src/chatbot/__about__.py b/src/chatbot/__about__.py index 01e50b9..aefefb6 100644 --- a/src/chatbot/__about__.py +++ b/src/chatbot/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2025-present Nam Le # # SPDX-License-Identifier: MIT -__version__ = "0.0.6" +__version__ = "0.0.7" diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/ui/app/components/HumanReviewForm.tsx b/ui/app/components/HumanReviewForm.tsx new file mode 100644 index 0000000..ac55034 --- /dev/null +++ b/ui/app/components/HumanReviewForm.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { HumanReview } from "../page"; + +interface HumanReviewFormProps { + searchQuery: string; + humanReview: HumanReview; + setHumanReview: (review: HumanReview) => void; + sendHumanReview: () => void; +} + +export default function HumanReviewForm({ + searchQuery, + humanReview, + setHumanReview, + sendHumanReview, +}: HumanReviewFormProps) { + return ( +
+ {/* Display the search query */} +

+ Chat model wants to search using this query:{" "} + {searchQuery} +

+ + {/* Human review form */} +
+ + {humanReview.action === "feedback" && ( +