LangGraphの"Human-in-the-loop"のUIを生成AIで楽して作った！

# はじめに
LangGraphを勉強中でQuickStartを写経しています。（[前回記事](https://qiita.com/akiraabe/items/027ffcf22a12772fabb9)）

Human-in-the-loopと呼ばれるパターンは、コマンドラインアプリだとなかなか動きを実感しづらくUIが欲しくなります。
そこで、今回は、大嶋さんがStudyCoでライブコーディングされていたプログラム（Streamlit）を参考にして、フロントエンド（React）＋バックエンド（FastAPI）の構成で作ってみました。

大嶋さんのStudyCoの動画アーカイブは[こちら](https://www.youtube.com/live/Y5zBzilgIm0?feature=shared)です。

# プログラムの動作の様子
先にプログラムの動作の様子をご覧いただきます。

## エージェントがツール利用を提案し人間が承認する場合

![hil_with_approve.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/bdcd2dfb-8c77-1784-dce7-b4972e2b7a39.gif)

## エージェントがツールを利用しない場合（承認は不要）
![hil_without_approve.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/64906c50-6f0a-e262-fde7-410ae12ff291.gif)


# 生成AIで楽をする！

（どんどん堕落している気がしますが）今回は上記のStreamlitのプログラムをベースとして、生成AIで楽をして作りました。
試してみたところ、一番良い感じの応答を返してくれたので、今回はClaude(3.5 Sonnet)を利用しています。

# 生成AIとのやりとりの様子

## 最初にベースを渡してプログラムを提案してもらう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130262/a7e8021e-edc0-0c29-b034-bd4389aa4542.png)

## バックエンドのプログラム（ほぼ生成AIの出力のままです）

main.py
```python:main.py
# main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from uuid import uuid4
from typing import List, Dict, Any
from agent import HumanInTheLoopAgent
import logging
from datetime import datetime

# python_dotenvを使って環境変数を読み込む
from dotenv import load_dotenv
load_dotenv(override=True)

# ログの設定
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')

# FastAPIアプリケーションのインスタンスを作成
app = FastAPI()

# CORSミドルウェアの追加
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # どこからでも許可
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# グローバル変数としてエージェントを保持
agent = HumanInTheLoopAgent()

# セッション管理のための簡易的なインメモリストア
sessions: Dict[str, Any] = {}

# メッセージのデータモデル
class Message(BaseModel):
    content: str
    type: str

# 会話の状態を表すデータモデル
class ConversationState(BaseModel):
    messages: List[Message]
    is_waiting_for_approval: bool

# 人間からのメッセージリクエストのデータモデル
class HumanMessageRequest(BaseModel):
    message: str

# 新しい会話を開始するエンドポイント
@app.post("/start_conversation")
async def start_conversation():
    logging.info(f"Method called: start_conversation")
    thread_id = uuid4().hex  # ユニークなスレッドIDを生成
    sessions[thread_id] = {
        "messages": [],
        "is_waiting_for_approval": False
    }
    return {"thread_id": thread_id}

# メッセージを送信するエンドポイント
@app.post("/send_message/{thread_id}")
async def send_message(thread_id: str, request: HumanMessageRequest):
    logging.info(f"Method called: send_message with thread_id: {thread_id}")
    if thread_id not in sessions:
        raise HTTPException(status_code=404, detail="Conversation not found")
    
    # エージェントにメッセージを処理させる
    agent.handle_human_message(request.message, thread_id)
    
    # エージェントからメッセージを取得
    messages = agent.get_messages(thread_id)
    is_waiting_for_approval = agent.is_next_human_review_node(thread_id)
    
    # セッションを更新
    sessions[thread_id] = {
        "messages": [Message(content=str(msg.content), type=msg.type) for msg in messages],
        "is_waiting_for_approval": is_waiting_for_approval
    }
    
    return ConversationState(**sessions[thread_id])

# メッセージを承認するエンドポイント
@app.post("/approve/{thread_id}")
async def approve(thread_id: str):
    logging.info(f"Method called: approve with thread_id: {thread_id}")
    if thread_id not in sessions:
        raise HTTPException(status_code=404, detail="Conversation not found")
    
    if not sessions[thread_id]["is_waiting_for_approval"]:
        raise HTTPException(status_code=400, detail="No approval pending")
    
    # エージェントに承認を処理させる
    agent.handle_approve(thread_id)
    
    # エージェントからメッセージを取得
    messages = agent.get_messages(thread_id)
    is_waiting_for_approval = agent.is_next_human_review_node(thread_id)
    
    # セッションを更新
    sessions[thread_id] = {
        "messages": [Message(content=str(msg.content), type=msg.type) for msg in messages],
        "is_waiting_for_approval": is_waiting_for_approval
    }
    
    return ConversationState(**sessions[thread_id])

# 会話の状態を取得するエンドポイント
@app.get("/conversation_state/{thread_id}")
async def get_conversation_state(thread_id: str):
    logging.info(f"Method called: get_conversation_state with thread_id: {thread_id}")
    if thread_id not in sessions:
        raise HTTPException(status_code=404, detail="Conversation not found")
    
    return ConversationState(**sessions[thread_id])

# アプリケーションを実行するためのエントリーポイント
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)


```

agent.py
こちらは、大嶋さんのコードがそのまま使えます！
https://github.com/os1ma/langgraph-human-in-the-loop-demo/blob/main/agent.py

## フロントエンドのプログラム（こちらもほぼ生成AIの出力おままです）

App.jsx

```jsx:App.jsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';

const API_BASE_URL = 'http://localhost:8000';

const ChatMessage = ({ message }) => (
  <div className={`message ${message.type}`}>
    {message.content ? (
      <p>{message.content}</p>
    ) : (
      <p>ツールが承認を求めています</p>
    )}
  </div>
);

const App = () => {
  const [threadId, setThreadId] = useState(null);
  const [messages, setMessages] = useState([]);
  const [inputMessage, setInputMessage] = useState('');
  const [isWaitingForApproval, setIsWaitingForApproval] = useState(false);

  useEffect(() => {
    const startConversation = async () => {
      try {
        const response = await axios.post(`${API_BASE_URL}/start_conversation`);
        setThreadId(response.data.thread_id);
      } catch (error) {
        console.error('Failed to start conversation:', error);
      }
    };

    startConversation();
  }, []);

  const sendMessage = async () => {
    if (!inputMessage.trim()) return;

    try {
      const response = await axios.post(`${API_BASE_URL}/send_message/${threadId}`, {
        message: inputMessage
      });
      setMessages(response.data.messages);
      setIsWaitingForApproval(response.data.is_waiting_for_approval);
      setInputMessage('');
    } catch (error) {
      console.error('Failed to send message:', error);
    }
  };

  const handleApprove = async () => {
    try {
      const response = await axios.post(`${API_BASE_URL}/approve/${threadId}`);
      setMessages(response.data.messages);
      setIsWaitingForApproval(response.data.is_waiting_for_approval);
    } catch (error) {
      console.error('Failed to approve:', error);
    }
  };

  return (
    <div className="chat-container">
      <h2>LangGraph Human-in-the-loop Chat</h2>
      {threadId && (
        <div className="thread-id">
          <p>Thread ID: {threadId}</p>
        </div>
      )}
      <div className="messages-container">
        {messages.map((message, index) => (
          <ChatMessage key={index} message={message} />
        ))}
      </div>
      <div className="input-container">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          placeholder="Type your message..."
        />
        <button onClick={sendMessage}>Send</button>
      </div>
      {isWaitingForApproval && (
        <div className="approval-container">
          <p>Waiting for approval...</p>
          <button onClick={handleApprove}>Approve</button>
        </div>
      )}
    </div>
  );
};

export default App;
```

# ソースコード全体

ソースコード全体はこちらにあります。
ちなみに、README.md、README_ja.mdも生成AIに書いてもらっています。

https://github.com/tis-abe-akira/human-in-the-loop2

# まとめ

振り返ってみると、ほとんどLangGraphについては説明していませんでした。（汗）

今回の個人的に収穫だったのは、とりあえず、Streamlitで最初にプログラムを作り、それを元にして生成AIを活用して、より拡張性が高い構成（フロントエンドとバックエンドを分離する）にすることが可能というのがわかったことです。
これが可能となった要因として、ベースにした大嶋さんのプログラムで`agent.py`が良い感じにコンポーネント化されていたらのが大きいと思います。改めて感謝を申し上げます！

# 注意点：

- このコードはデモンストレーション目的のものであり、本番環境での使用には適切なエラーハンドリング、セキュリティ対策、状態管理の最適化などが必要です。
- CORSの設定やセキュリティの考慮が必要です。本番環境では適切に設定してください。
- 状態管理にはインメモリストアを使用していますが、本番環境では永続的なデータベースの使用を検討してください。