見出し画像

かんたんLINE風チャットアプリ



LLMにロールを与えてキャラクタ化し、会話できるかんたんアプリです。
LLMはOpenAIも使えますし、ローカルでllama.cpp のようなOpenAI互換APIを持つ環境で、各自のGPUのVRAMの大きさに合わせたモデルを動かしておけば大丈夫。

WEBアプリなんですけど、会話はLINE風のデザインでLLMからの返事はStreamingされ、会話の進行と同時にスクロールします。目新しいところはありませんが、シンプルなコードなので、アプリのベースに利用していただけたらと思います。

環境


まず仮想環境を作成します。必然ではありませんが、他の環境と混ざり合わないようにするには、何かと便利なのでぜひ利用してください。

仮想環境作成
python3.11 -m venv webui
仮想環境の有効化
source web/bin/activate
サーバはFastAPIなので、FastAPIをインストール
pip install fastapi
OpenAI用のAPIを動かすので、OpenAIモジュールをインストール
pip install openai

LLMサーバ側


llama.cppを動かすなら、過去記事参考にしてください。今回の記事も,このときのアプリの発展形です。

バックエンド

以下がコードです。めちゃ簡単。サーバアドレスを変えるには最下行の
uvicorn.run(app, host="127.0.0.1", port=8004)
を変えます。

OpenAI互換サーバの定義

冒頭のこの部分、base_urlとapi_keyをOpenAI用に変えればOpenAIにもアクセスできます。中程のmodel="gpt-4",を適切なモデル名に変えましょう。ローカルで動かすのであれば何も心配ありません。
client = AsyncOpenAI(
base_url='http://192.168.5.71:8080/v1',
api_key="YOUR_OPENAI_API_KEY",) # このままでOK

htmlへのアクセス定義

FastAPIでルートへルートにアクセス(ブラウザのウRLlを
uvicorn.run(app, host="127.0.0.1", port=8004)
で指定したアドレスにする)すると、最初のルータに飛んできます。

@app.get("/", response_class=HTMLResponse)
async def get():
    with open('static/index4A.html', 'r') as f:
        return f.read()

staticにあるindex4A.htmlを読み込む動作をします。したがってstaticディレクトリを作成してindex4A.htmlを格納しておきます。

以下のコードをopenai_gui_m4A.pyなどに名前で、冒頭で作成した仮想環境(webui)のディレクトリに作成しておきます。上記のstaticディレクトリも作成して起きましょう。

バックエンドコード

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from openai import AsyncOpenAI
import json

app = FastAPI()

client = AsyncOpenAI(
    base_url='http://192.168.5.71:8080/v1',
    api_key="YOUR_OPENAI_API_KEY",  # このままでOK
)


@app.get("/", response_class=HTMLResponse)
async def get():
    with open('static/index4A.html', 'r') as f:
        return f.read()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
            data = await websocket.receive_text()
            data_dict = json.loads(data)  # 受信したJSONデータをPython辞書に変換
            message = data_dict.get("message")
            role = data_dict.get("role")
            print(f"Received message: {message} with role: {role}")
            stream = await client.chat.completions.create(
                model="gpt-4",
                messages=[{"role": role, "content": message}],
                stream=True
            )
            response_buffer = []
            async for chunk in stream:
                if chunk.choices[0].delta.content:
                    response_buffer.append(chunk.choices[0].delta.content)
                    # チャンクをリアルタイムで送信
                    await  websocket.send_text(chunk.choices[0].delta.content)
            response_sum = "".join(response_buffer)
            print("chunk sum  ==>", response_sum)
            print("+++++++++++++++++++++++++++++++++++++")


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8004)

HTML

1つのファイルにHTMLとJavaScriptとcssが記述されているのでやや長いです。ファイル名をindex4A.htmlとして作成したディレクトリに配置します。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Chat with llama.cpp</title>
    </head>
    <body>
        <h1>Chat with llama.cpp</h1>
        <div id="mainArea">
            <div id="responses"></div>
            <div>
                <div id="roleArea">
                    <h4>LLMへのRole</h6>
                    <textarea type="text" id="roleText" placeholder="LLMのロール"></textarea>
                </div>
                <div id="inputArea">
                    <h4>メッセージ・質問・会話・指示</h6>
                    <textarea type="text" id="inputText" placeholder="メッセージを書く"></textarea>
                    <h4>会話ターン記憶数</h6>
                    <input type="number" id="numberInput" min="0" max="20" placeholder="0-20">
                    <h4>LLMの名前</h6>
                    <input type="text" id="nameInput" placeholder="LLMのキャラ名">
                </div>
            </div>
        </div>
    </body>
</html>

<script>
    const ws = new WebSocket("ws://127.0.0.1:8004/ws");
    let isFirstResponse = true;  // フラグを初期化
    const messagesContainer = document.getElementById('responses');  // コンテナを取得
    let savedRoleMessage = "";  // roleMessageを保存するためのグローバル変数
    let LastChank = "";
    let lastMessage = "";
    let conversationLogs = [];  // 会話ログを保存する配列
    const defaultMaxLogs = 5;   // デフォルトの最大ログ数
    let singleTurn = "";
    let currentUserName = "";  // ユーザー名を保存するためのグローバル変数

    document.querySelectorAll('textarea').forEach(textarea => {
        textarea.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = (this.scrollHeight) + 'px';
        }, false);
    });

    // Enterキーでメッセージを送信
    document.getElementById('inputText').addEventListener('keydown', function(event) {
        if (event.key === 'Enter' && !event.shiftKey) {  // Shift + Enterで改行
            event.preventDefault();  // ページリロード防止
            sendMessage();  // メッセージ送信
        }
    });

    ws.onmessage = function(event) {
        if (isFirstResponse) {
            const initialDiv = document.createElement('div');
            initialDiv.className = 'response-message assistant';  // Assistant message on the left
            initialDiv.innerHTML = `<strong style="color: red;">${currentUserName}:</strong> `;
            messagesContainer.appendChild(initialDiv);
            isFirstResponse = false;}
        const lastMessageDiv = messagesContainer.lastElementChild;
        if (lastMessageDiv && lastMessageDiv.classList.contains('response-message')) {
            const span = document.createElement('span');
            span.innerHTML = event.data;
            lastMessageDiv.appendChild(span);
            LastChank += event.data;} 
        else {
            const newMessageDiv = document.createElement('div');
            newMessageDiv.className = 'response-message assistant';  // Assistant message on the left
            newMessageDiv.innerHTML = event.data;
            messagesContainer.appendChild(newMessageDiv);
            LastChank += event.data;}
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    };

    function sendMessage() {
        const inputElement = document.getElementById('inputText');
        const roleElement = document.getElementById('roleText');
        const numberElement = document.getElementById('numberInput');
        const nameElement = document.getElementById('nameInput');
        const name = nameElement.value.trim();
        const message = inputElement.value;
        let roleMessage = roleElement.value.trim();
    
        if (!message.trim() && !name.trim()) {
            alert("Please enter llm name and a message.");
            return;}
        if (roleMessage === "") {
            roleMessage = savedRoleMessage;} 
        else {
            savedRoleMessage = roleMessage;}
        currentUserName = name;
        if (message.trim() === "" && roleMessage === "") return;
        if (lastMessage !== "") {
            singleTurn = 'user:' + lastMessage + 'response:' + LastChank;
            console.log("+++++singleTurn=", singleTurn);}
        LastChank = "";
        lastMessage = message;
        const conv_log = addLog(singleTurn);
        const NewPrompt = conv_log + 'user:' + message;
        const userDiv = document.createElement('div');
        userDiv.className = 'user-message';  // User message on the right
        userDiv.innerHTML = `<strong style="color: blue;">You:</strong> ${message}`;
        messagesContainer.appendChild(userDiv);
        ws.send(JSON.stringify({ message: NewPrompt, role: roleMessage }));
        inputElement.value = '';
        isFirstResponse = true;
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }

    function addLog(message) {
        const maxLogCount = document.getElementById('numberInput').value || defaultMaxLogs;
        const logEntry = { message };
        conversationLogs.push(logEntry);
        if (conversationLogs.length > maxLogCount) {
            conversationLogs.shift();}
        return conversationLogs.map(log => log.message).join('\n');
    }
</script>

<style>
    body {
        display: flex;
        flex-direction: column;
        align-items: center;
        margin: 0;
        padding: 20px;}
    #mainArea {
        display: flex;
        width: 100%;}
    #inputArea, #roleArea {
        width: 100%;
        padding: 10px;
        padding-top:0px;
        margin-top:-20px;}
    textarea, input[type="text"], input[type="number"] {
        width: 100%;
        padding: 10px;
        box-sizing: border-box;
        margin-bottom: 5px;
        font-size: 18px;}
    h4 {margin-bottom: 5px;}
    #responses {
        width: 65%;
        height: 400px;
        overflow-y: auto;
        border: 1px solid #ccc;
        padding: 10px;
        background-color: #f9f9f9;
        white-space: pre-wrap;}
    .user-message, .response-message {
        margin: 5px 0;
        padding: 10px;
        border-radius: 10px;}
    .user-message {
        background-color: #d1ecf1;
        text-align: right;
        margin-left: auto;
        width: fit-content;
        max-width: 80%;}
    .response-message {
        background-color: #f8d7da;
        text-align: left;
        margin-right: auto;
        width: fit-content;
        max-width: 80%;}
</style>

動かし方

cd webui
python openai_gui_m4A.py

実際のブラウザ表示

まとめ

うまくライン風のデザインで作成できました。FastAPIとhtmlを使い、簡単なコードでチャットが作成できました。教科書には色々とややこしいことが書いてありますが、実施はとてもシンプルです。今回のデザインのチャット画面もよく見ると思いますが、ソースコードを見る機会は少ないと思います。参考にしてください。