もふもふ技術部

IT技術系mofmofメディア

LangChainとNext.jsで簡単なチャットBotを作ってみる

JavaScript版のLangChainを触る機会があったので、備忘録も兼ねて簡単なチャットBOTを作ってみます。

構成

  • さくっと動かしたいのでNext.jsのAPI Routes上でLangChainを使います。
  • 簡略化のためにチャット履歴はメモリ上に保存します。※Nextを再起動するとチャット履歴がリセットされます。
  • エラーハンドリングはほとんど行っていません。

LangChain周り

LangChainには大きく分けて6つの機能がありますが、今回は下記の3つを使用します。

Model

各種LLM等を使いやすく抽象化してくれているもの
今回は「ChatOpenAI」を使用します

Memory

過去の対話履歴を記録するための機能
下記のように記録形式が異なるMemoryが用意されています。 - 過去の対話を全て記録 - 過去の対話をn回分記録 - 過去の対話を要約して記録

今回は過去の対話をn回分記録する「BufferWindowMemory」を使用します

Chain

各種機能を組み合わせて一連の処理として実行できる機能。
今回は対話用Chainの「ConversationChain」を使用します。

サンプル

今回作った物のリポジトリです。
https://github.com/HiroKb/langchain-next-chat

create next app & LangChainのインストール

npx create-next-app@latest

✔ What is your project named? … langchain-chat
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias? … Yes
✔ What import alias would you like configured? … @/*

cd langchain-chat
npm install -S langchain

npm run dev

チャットメッセージの取得と表示

LangChainが絡まない部分を先に作ってしまいます。

API

メッセージの取得処理を作成します。
/src/app/api/messages/route.ts

import {NextResponse} from "next/server";

export const Role = {
    Assistant: 'assistant',
    User: 'user'
} as const

export type Message = {
    id: number
    content: string
    role: typeof Role[keyof typeof Role]
}

// メモリ上にチャット履歴を保持する。ひとまず仮データ
const messages: Message[] = [
    {id: 1, content: 'User Message1', role: Role.User},
    {id: 2, content: 'Assistant Message1', role: Role.Assistant},
    {id: 3, content: 'User Message2', role: Role.User},
    {id: 4, content: 'Assistant Message2', role: Role.Assistant},
]

// GET /api/messagesにマッピングされる
export async function GET() {
    return NextResponse.json({messages: messages})
}

Client

メッセージの取得/表示部分と、メッセージの入力フォームを作成します
/src/app/(chat)/page.tsx

import {Message} from "@/app/api/messages/route";

import ChatMessageForm from "@/app/(chat)/ChatMessageForm";
import Messages from "@/app/(chat)/Messages";

const getMessages = async () => {
    const res = await fetch('http://localhost:3000/api/messages', {
        cache: 'no-store'
    })

    if (!res.ok) throw new Error('Fetch Error')

    const json = await res.json()
    return json.messages as Message[]
}

export default async function Home() {
    const messages = await getMessages()

    return (
        <main className="flex min-h-screen justify-center">
            <div className="w-2/3 relative">
                <div className="py-10 mb-14">
                    <Messages messages={messages}/>
                </div>
                <div className="absolute w-full bottom-0 pb-5">
                    <hr className="border-gray-300"/>
                    <MessageForm messages={messages}/>
                </div>
            </div>
        </main>
    )
}

/src/app/(chat)/Messages.tsx

import {Message, Role} from "@/app/api/messages/route";

type Props = {
    messages: Message[]
}

export default function Messages({messages}: Props) {
    return (
        <ul className="space-y-4">
            {messages.map((message) => (
                message.role === Role.Assistant
                    ? <li className="flex justify-start" key={message.id}>
                        <div className="px-4 py-2 text-gray-700 bg-white rounded shadow">
                            <pre className="whitespace-pre-wrap">{message.content}</pre>
                        </div>
                    </li>
                    : <li className="flex justify-end" key={message.id}>
                        <div className="px-4 py-2 text-gray-900 bg-gray-300 rounded shadow">
                            <pre className="whitespace-pre-wrap">{message.content}</pre>
                        </div>
                    </li>
            ))}
        </ul>
    );
}

/src/app/(chat)/MessageForm.tsx

'use client'
import React, {createRef, useEffect, useState} from "react";
import {Message} from "@/app/api/messages/route";

type Props = {
    messages: Message[]
}

export default function MessageForm({messages}: Props){
    const formRef = createRef<HTMLFormElement>()

    const [message, setMessage] = useState<string>('')
    const [sending, setSending] = useState<boolean>(false)

    useEffect(() => {
        formRef?.current?.scrollIntoView({behavior: 'smooth'})
    },[messages])

    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault()
        if (sending || message === '') return

        setSending(true)
        setMessage('')
        setSending(false)
    }

    return (
        <form
            className="flex items-center justify-between w-full border-gray-300 my-3 space-x-3.5"
            onSubmit={handleSubmit}
            ref={formRef}
        >
            <input
                value={message}
                onChange={(e) => setMessage(e.target.value)}
                type="text"
                placeholder="Message"
                className="block w-full py-2 px-4 bg-gray-100 rounded-full outline-none focus:text-gray-700"
                name="message"
                required
            />
            {
                !sending
                    ? <button
                        type="submit"
                    >
                        <svg
                            className="w-5 h-5 text-gray-500 origin-center transform rotate-90"
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 20 20"
                            fill="currentColor"
                        >
                            <path
                                d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"
                            />
                        </svg>
                    </button>
                    : <div className="animate-spin h-5 w-5 border-4 border-gray-700 rounded-full border-t-transparent"></div>
            }
        </form>
    );
}

元々存在した/src/app/page.tsxは削除します。

ここまででhttp://localhost:3000/にアクセスすると以下のようなチャット画面が表示されるかと思います。 next-langchain-chat-tmp

LangChainを介してOpenAIに回答を生成してもらう

まずはOpenAIのAPI keyを環境変数に設定します
.env

OPENAI_API_KEY=<your-api-key>

API

回答の生成とチャット履歴の保存処理を追加します。
/src/app/api/messages/route.ts

import {BufferWindowMemory, ChatMessageHistory} from "langchain/memory";
import {AIMessage, HumanMessage} from "langchain/schema";
import {ConversationChain} from "langchain/chains";
import {ChatOpenAI} from "langchain/chat_models/openai";

// POST /api/messagesにマッピングされる
export async function POST(req: Request) {
    // 入力内容の取得
    const {message: userMessageContent} = await req.json()

    // 対話履歴をLangChain用に変換
    const chatMessageHistory = new ChatMessageHistory(messages.map(message => {
        return message.role === Role.Assistant
            ? new AIMessage(message.content)
            : new HumanMessage(message.content)
    }))
    const memory = new BufferWindowMemory({
        k: 4, // 過去4回分の対話を使用する
        chatHistory: chatMessageHistory,
    })
    // 生成用のmodelを作成する。
    // openAIApiKeyを指定しない場合、環境変数内のOPENAI_API_KEYに設定した値を読み取ってくれる
    const model = new ChatOpenAI({
        modelName: "gpt-3.5-turbo",
        // openAIApiKey: process.env.OPENAI_API_KEY,
    })

    // 対話用Chainに過去の対話履歴を埋め込んで作成し、呼び出す
    const chain = new ConversationChain({
        llm: model,
        memory: memory,
        verbose: false // verboseをtrueにすると処理内容などが出力される
    });
    const chainResult = await chain.call({input: userMessageContent});
    const assistantMessageContent = chainResult.response 

    // メモリ上のチャット履歴にメッセージを追加
    const userMessage = {
        id: messages.length + 1,
        content: userMessageContent,
        role: Role.User
    }
    messages.push(userMessage)
    const assistantMessage = {
        id: messages.length + 1,
        content: assistantMessageContent,
        role: Role.Assistant
    }
    messages.push(assistantMessage)

    return new Response()
}

messages配列に入れていた仮データは削除しておきます。

// メモリ上にチャット履歴を保持する
const messages: Message[] = []

Client

フォームコンポーネントにメッセージの登録処理を追加します。
/src/app/(chat)/MessageForm.tsx

import {useRouter} from "next/navigation";

~~~~省略~~~~

const router = useRouter()

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (sending || message === '') return

    setSending(true)

    // メッセージの追加後、全メッセージを再取得する
    await fetch('http://localhost:3000/api/messages', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({message}),
    })

    router.refresh()

    setMessage('')
    setSending(false)
}

チャットしてみる

ここまでで最低限チャットできるようになりました。
以下のように過去の対話履歴を記憶した状態で応答してくれます。 next-langchain-chat

おわりに

実はこの程度のチャットBOTであれば、LangChainではなくOpenAI公式のライブラリを使用したほうが簡単に実装できたりします。
ただ、もっと複雑なことをしたい場合等はLangChainが複雑な部分を隠蔽してよしなにやってくれるので便利だなーという印象でした。
作るものによって使い分けていけると良いですね。