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