💡

【React+Chat-ui-kit】LLM時代の手作りチャット画面の作成手順

に公開

概要

LLMの登場により, アプリケーション面ではチャット画面を実装したいという要望はあると思う.
本記事では, お手軽にブラウザ上で動くチャット画面の作成ライブラリchat-ui-kitを紹介する.
Reactの上で動くため, 軽量かつシンプルな実装ができることが魅力だと思う.

技術構成

  • ブラウザ・チャット画面にはjavascript(node.js)を使用する

  • 主な技術構成は以下の通り

    • ブラウザの画面: NextJS (Reactの派生)
    • +型定義: typescript
    • デザイン: bootstrap
    • チャット画面: chat-ui-kit-react
  • MacBookPro/OS: Sequioa 15.5

準備

node.js関連や, チャット画面を作成するchat-ui-kit-reactをインストールする
本記事では, Ollamaを用いた対話型エージェントも実装するが, その導入部分については以前の記事で説明しているためここでは省く.
https://zenn.dev/akitek/articles/4dcf81dff67587

next.js

Next.jsは, ReactベースのJavaScriptフレームワークです.
Reactとは何が違うの?って方は以下の記事を参考にして下さい😇

https://qiita.com/yutakiya/items/371fec6e878e57fffa08

好きなディレクトリを作成し, 以下のコマンドを作成する.

mkdir samlplechat
cd samplechat
npx create-next-app@latest  # これだけ

このコマンドのあと、画面上にいろいろなオプションが出てくるので以下のように答える

✔ What is your project named? … my-app
✔ Would you like to use TypeScript? …  Yes
✔ Would you like to use ESLint? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack for `next dev`? … Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No
Creating a new Next.js app in ...

I番目の質問は, アプリ(のファイル)名, 2番目の質問は, typescript.jsの使用の有無...
以下の記事も参考にしてください.
https://zenn.dev/kame_koki/articles/39d05c7b8a5a8b

https://www.typescriptlang.org
https://qiita.com/MadakaHeri/items/45e514dfbbc85fd64c77


https://zenn.dev/ikuraikura/articles/71b917ab11ae690e3cd7
https://zenn.dev/ryo7/articles/node-npm-update-on-mac
https://qiita.com/kiharito/items/4785d4d54c967b8ddc9a
https://zenn.dev/sprout2000/articles/bd1fac2f3f83bc


chat-ui-kit

https://chatscope.io

chat-ui-kitは, Web チャットアプリ開発向けのオープンソース UI ツールキットで, reactとの相性が良い.

install

インストールするには以下のコマンドをターミナルでうつ.

npm install @chatscope/chat-ui-kit-react
npm install @chatscope/chat-ui-kit-styles

基本要素

chat.tsx
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
  MainContainer,
  ChatContainer,
  MessageList,
  Message,
  MessageInput,
} from "@chatscope/chat-ui-kit-react";

<div style={{ position: "relative", height: "500px" }}>
  <MainContainer>
    <ChatContainer>
      <MessageList>
        <Message
          model={{
            message: "Hello my friend",
            sentTime: "just now",
            sender: "Joe",
          }}
        />
      </MessageList>
      <MessageInput placeholder="Type message here" />
    </ChatContainer>
  </MainContainer>
</div>;

chat-ui-kitの説明をした日本語記事はまだまだ少ない. が,最低限の要素について説明しておく.
<MessageList>内がチャットスペースのメッセージ置き場となる.
そして,<Message>がそのメッセージそのものとなる. つまり, <Message>要素が5個含まれていれば, 画面上にはメッセージが5個表示されることとなる.

さて, <Message>要素には, 各メッセージを識別するkeyと, メッセージのデザイン・内容を決めるmodelがある. また, <Message>要素のさらに内側にアバターアイコンである<Avatar>を含めることができる.

つぎに, <MessageList>要素の下に配置する<MessageInput>は, 問い合わせ入力枠を作成する. <MessageInput>要素には, 送信ボタンを押した後の処理onSendや, 送信ボタンを押せるかどうかのsendDisabledがある.
後者は, 何か送信処理中は送信ボタンを押せないようにするなどの制御に使える.

具体例をもう少し知りたい場合は以下の記事を参考にしてください.
https://qiita.com/Ueken3pei/items/a4290883840b23019742

残り

デザインにbootstrap, LLMからの応答文を整形するためにmarkedを導入しておく.

npm install bootstrap # デザイン系
npm install marked # Markdownをhtmlに変換する

LLMとチャットしてみる

道具が揃ったので, チャット機能をUIから作っていこう.
全体の構成として,以下を想定する.

  1. チャットの画面はブラウザ(http://localhost:3000/)
  2. LLMやブラウザとのやりとりはPythonサーバー(http://localhost:8080/)
  3. LLMはOllamaのサーバーを介して行う(http://localhost:11434/)

またファイル階層は以下の通りとする.

/my-app
├── /ChatAgent
│   ├── main.py # サーバー起動/ブラウザとの通信
│   └── agent.py # LLMエージェント
├── /src
│   ├── /app # 基本ページ
│   │   └── page.tsx
│   ├── /components # 
│   │   └── Chat.tsx
│   ├── /context # 状態変数の置き場
│       └── GroupContext.py  
└── /public # アイコン画像置き場
コード例
main.py
import os
import json
import warnings
import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer

from Agent import Agent

warnings.simplefilter('ignore')

os.environ['HTTP_PROXY'] = ''
os.environ['HTTPS_PROXY'] = ''

agent       = None # 本サーバーで稼働するエージェント

def Conversation(agent, requestText):
    return agent.conversation(requestText)

class MyHandler(BaseHTTPRequestHandler):
    def do_POST(self): # POST リクエストを受けた場合
        global agent
        print(self.headers)
        content_len=int(self.headers.get('content-length'))
        requestBody = self.rfile.read(content_len).decode('utf-8')
        
        # サーバー側のコマンドプロンプト出てくる表記
        print('requestBody=' + requestBody)
        jsonData = json.loads(requestBody)
        print('**JSON**')
        
        errors = [] # 処理中のエラーを列挙
        data   = jsonData["data"]
        action = data["action"]
        if action:
            if action == "chat":
                # 会話したい場合
                requestText = data["query"]
                response = Conversation(agent, requestText)
                self.send_response(200)
                self.send_header('Content-type', 'application/json')
                self.send_header('Access-Control-Allow-Origin', '*')         
                self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
                self.end_headers()
                responseBody = json.dumps({"status": 200, 
                                           "result": response})
                self.wfile.write(responseBody.encode("utf-8"))     
def importargs():
    parser = argparse.ArgumentParser("これは練習用サーバー")
    parser.add_argument("--host", "-H", required=False, default="localhost")
    parser.add_argument("--port", "-P", required=False, type=int, default=8000)

    args = parser.parse_args()

    return args.host, args.port

def run(server_class = HTTPServer, handler_class = MyHandler, server_name = "localhost", port=8000):
    server = server_class((server_name, port), handler_class)
    print("サーバー稼働中")
    server.serve_forever()

def main():
    global agent
    # サーバーを立ててデータの受信を待つ
    host, port = importargs()            # サーバー情報の取得
    agent = Agent()
    run(server_name = host, port = port) # サーバーを起動
if __name__ == "__main__":
    main()
Agent.py
import os
os.environ['HTTP_PROXY'] = ''
os.environ['HTTPS_PROXY'] = ''
from langchain_community.llms import Ollama

class Agent:
    def __init__(self):
        # ローカルLLMを搭載する
        self.llm        = Ollama(
                            base_url="http://localhost:11434",
                            model="elyza:jp8b") # 作成したモデル名を指定

    def conversation(self, text):
        return self.llm.invoke(text)
page.tsx
'use client' // 強制的にクライアントレンダーへ
// import dynamic from "next/dynamic";
import 'bootstrap/dist/css/bootstrap.min.css'; // BootstrapのCSSをインポート

import { GroupsProvider } from '@/context/GroupsContext';
import Chat from "../components/Chat";

export default function Home() {
  return (
    <GroupsProvider>     
      {/* ヘッダー */}     
      <div className="d-flex flex-column" style={{width: "100%"}}>
        <header className="bg-white text-center py-2 mt-1">
          <h5>{"bot"}</h5>
        </header>
      </div>
      <div id="home" className="container-fluid vh-100 d-flex justify-content-center" 
      style={{ backgroundColor: '#f8f9fa', overflowY: "hidden"}}>
          <div className="d-flex justify-content-center" style={{ width: '40%', padding: '1rem', borderLeft: '1px solid #ddd',borderRight: '1px solid #ddd'  }}>
            <div className="bg-light p-3" style={{width: "100%", height: "95%"}}>
              <div className="shadow p-2 mb-1 rounded bg-secondary text-white ">
                チャットスペース</div>
              <Chat />
            </div>
          </div>
      </div>
    </GroupsProvider>
  );
}
Chat.tsx
import React, { useState } from "react";
import { useRoadGroups } from '@/context/GroupsContext';
import { MainContainer, ChatContainer, MessageList, 
    Message, MessageInput, Avatar, TypingIndicator, Button } from "@chatscope/chat-ui-kit-react";
import  "@chatscope/chat-ui-kit-styles/dist/default/styles.css"

import { Marked } from "marked";
const initialMessages = {message: "質問をしてください", sender: "Agent", direction: "incoming"}

const AvaterIcon = (avaterName: string) => {
    if (avaterName === "user") {
        return "自分のアイコン.png"
    } else if (avaterName === "Agent") {
        return "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg"
    }
}

const marked = new Marked(
    {
        gfm: true,
        breaks: true,
    }
)

const Chat = () => {
    const [messages, setMessages] = useState([initialMessages]);
    const {isLoading, setIsLoading, isReady, setIsReady} = useRoadGroups();
    setIsReady(true)
    setIsLoading(false) 
    const sendQuery = (text: string, action: string) => {
        let message = {message: text, sender: "user", direction: "outgoing", type: "text"};
        setMessages((prev) => [...prev, message]);
        // LLMへ問い合わせ
        const jsonData = {
            "data":{
                "action" : action, 
                "query": text
                }
            }
        const data = JSON.stringify(jsonData)
        const options = {
            method: 'POST',
            body: data,
          };
        console.log(data)
        setIsLoading(true)
        fetch("http://localhost:8000/", options)
        .then(response => {
            if (!response.ok){
                return Promise.reject(new Error('エラーです'));
            }
            return response.json()
        })
        .then(data => {
            // 処理成功の場
            console.log(data)
            let result = data["result"] 
            message = {message: marked.parse(result), sender: "Agent", direction: "incoming", type: "html"};
            setMessages((prev) => [...prev, message]); 

            setIsLoading(false) 
        })
        .catch(error => {
            // エラーを受けた場合
            console.error('There was a problem with the fetch operation:', error);
            message = {message: "もう一度送信してください...", sender: "Agent", direction: "incoming", type: "text"};
            setMessages((prev) => [...prev, message]); 

            setIsLoading(false)
            alert(error)
        });
    }

    return (
        <div style={{ position: "relative", height: "95%", fontSize: "1em"}}>
            <MainContainer>
                <ChatContainer>
                    <MessageList typingIndicator={isLoading ? <TypingIndicator content="問い合わせ中..." /> : null}>
                        {messages.map((m, mi) => 
                        (<Message
                            key={mi}
                            model={{
                                message: m.message,
                                sentTime: "just now",
                                sender: m.sender,
                                position: "normal",
                                direction: m.direction,
                                type: m.type
                            }}
                            >
                                <Avatar
                                    name= {m.sender}
                                    src= {AvaterIcon(m.sender)}
                                    />
                            </Message>
                            ) 
                        )}
                    </MessageList>
                    <MessageInput 
                        placeholder="問い合わせを記入してください..." 
                        onSend = {(innerHtml, textContent, message) => sendQuery(message, "chat")}
                        sendDisabled={!isReady || isLoading} />
                </ChatContainer>
            </MainContainer>
        </div>
    )
};

export default Chat;
GroupsContext.tsx
import React, { createContext, useContext, useState } from "react";


type ContextProps = {
    isLoading: boolean;
    setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
    isReady: boolean;
    setIsReady: React.Dispatch<React.SetStateAction<boolean>>;
};

const GroupsContext = createContext<ContextProps | undefined>(undefined);

export const useRoadGroups = () => {
    const context = useContext(GroupsContext);
    if (!context) throw new Error("useRoadGroups must be within a Provider");
    return context;
};

export const GroupsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    const [isLoading, setIsLoading] = useState<boolean>(false); 
    const [isReady, setIsReady] = useState<boolean>(false);
    return (
        <GroupsContext.Provider value = {{isLoading, setIsLoading, isReady, setIsReady}}>
            {children}
        </GroupsContext.Provider>
    )
}

上記の画面が作成できれば成功. チャット画面を作成することができるのでぜひトライしてみよう.

まとめ

本記事では, お手軽にブラウザ上で動くチャット画面の作成ライブラリchat-ui-kitを紹介した.
以下に, ChatGPTに聞いた他の実装方法も示しておく. 参考になれば幸いである.

目的 おすすめ
軽量なチャットUIを自作したい react-chat-ui / react-chat-elements
チャットボット的な対話UIが欲しい BotUI
本格的なチャットをすぐ使いたい Stream Chat
完全自作+リアルタイム処理も自分で socket.io + 任意のUI
比較説明

🧱 ① UIを素早く作れるチャット用UIライブラリ・コンポーネント

✅ react-chat-ui
GitHub: https://github.com/brandonmowat/react-chat-ui
特徴:
Reactベースでチャットバブル・送受信・リストなどが揃っている
シンプルなAPIとUI構成
向いている用途: 「自分でバックエンドを作る or 外部APIと連携したチャットUI」
npm install react-chat-ui
✅ react-chat-elements
GitHub: https://github.com/Detaysoft/react-chat-elements
特徴:
よりリッチなUI(日時表示・ユーザーアバター・メッセージタイプ別など)
チャットアプリらしいデザインが標準で組み込まれている
UI例: メッセージ、リスト、送信ボタン、画像・動画・音声対応
npm install react-chat-elements
✅ BotUI
URL: https://botui.org/
特徴:
チャットボット風のUIを簡単に作れる
非React系。CDN読み込みでもOK
向いている用途: 「ステップ形式のチャット」「FAQ形式の会話ボット」
<script src="https://cdn.jsdelivr.net/npm/botui/build/botui.min.js"></script>
🛠 ② UI+リアルタイム通信込み(バックエンド連携前提)

✅ Stream Chat UI Kit
URL: https://getstream.io/chat/
特徴:
完全なチャットUI+リアルタイムAPI(無料枠あり)
React用UIキットが非常に充実
ビジネス用チャット、サポートチャットなどにも強い
使い方: Reactコンポーネントを並べてAPIキーと接続すれば動作
npm install stream-chat stream-chat-react
✅ socket.io + 自作UI
URL: https://socket.io/
特徴:
WebSocketベースでリアルタイム通信を実装
UIは自作(or 上記のUIライブラリと組み合わせ)
向いている用途: 学習目的・完全自作したい場合
🧩 ③ Tailwindやshadcn/uiを使ってチャットUIを自作する場合

もしあなたが Next.js + Tailwind + shadcn/ui のような構成で開発している場合、以下の方法もおすすめです:

TailwindのFlex/Gap構成でバブルUIを作成
shadcn/ui の Input や Card コンポーネントで簡易チャットUI構成
メッセージは overflow-y-scroll + max-h を使えばスクロール式に

参考サイト

https://qiita.com/HYKTSNG/items/a56f95b2f8a1edd96a66

本記事で扱ったchat-ui-kit以外の選択肢にも検討しているので参考にできるかも

https://zenn.dev/enken/articles/enken-python-http-server

Pythonの標準ライブラリであるhttp.serverの使用方法

Discussion