
オリジナルとはどういうことですか?

私は下の動画のように黒板風にアレンジしてみました。赤字がユーザーの質問で白字がChatGPTの応答です。
OpenAI APIキーの取得
まず、今回使用するAPIサービス、OpenAIのAPIキーを取得します。APIとは、Application Programming Interface(アプリケーション プログラミング インターフェース)といって、Webサービスやソフトウェアの機能を別のWebサービスやソフトウェアから呼び出せるようにしたものです
OpenAIのAPIキーを取得する流れは以下の通りです(以下公式サイト)。

- OpenAIの公式サイトにアクセスします
- 画面が開いたらProducts ⇒ API Login をクリックします。
- ログイン画面が表示されるのでログインします。アカウントを作成していなければSIGN UPしてアカウント作成から進めて下さい。
- ログイン後、「ChatGPT」 か「API」 を選択する画面が表示されるので、「API」 を選択します。
- 画面が切り替わったら左側のメニューから「API Keys」をクリックします。
- 「Create new secret key」をクリックして、APIキー作成フォーム画面に切り替わったら内容に従ってAPI キーを作成します。
- 作成されたAPI キーは安全な場所に保存して下さい。APIキーは再度表示されません。
ChatGPT APIキー取得するにあたっての注意事項
- こちらのブログではJavaScriptコード内にAPIキーを記述していますが、実際はセキュリティを考慮した構築をする必要があります。APIキーの取り扱いには十分注意して間違って公開しないようにして下さい。
- OpenAI APIの利用料金は、APIの取得自体に料金はかかりませんが、利用した分だけ課金される従量課金制です。モデルによって異なりますが、GPT-3.5 Turboで1M(メガ)トークン単位で入出力合わせておよそ$4以下と非常にリーズナブルではありますが、最新の料金体系はこちらで確認して下さい。
実行環境の整備
今回の実装はNode.jsは使わない仕様で作成します。また、記述もVanilla JSで作成し、jQueryも使いません。ディレクトリ構造は以下の通りです。

HTMLファイルの作成
まず、HTMLファイルを記述します。
主な要素はメッセージを入力するフォームとユーザーとChatGPTのやり取りを表示するウィンドウです。他にサンプルの画像などを付け込みました。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Original ChatGPT</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Zen+Kurenaido&display=swap" rel="stylesheet">
</head>
<body>
<div id="message-container">
<div id="message-window">
<div id="message-history"></div>
</div>
<div id="chalk-pink"></div>
<div id="chalk-white"></div>
</div>
<img src="sample.png" alt="サンプル画像" width="250px" height="200px">
<form id="message-form">
<input type="text" id="message-input" placeholder="メッセージを入力...">
</form>
<script src="script.js"></script>
</body>
</html>
JavaScriptプログラムの作成
次にJSファイルを記述します。最初に各種のDOM要素を取得します。
const getElement = (id) => document.getElementById(id);
const form = getElement("message-form");
const input = getElement("message-input");
const history = getElement("message-history");
const msgWindow = getElement("message-window");
メッセージをウィンドウに追加する関数
次から必要な関数を設定していきます。
const appendMessage = (text, className) => {
const element = document.createElement("p"); // 新しい<p>要素を作成
element.className = className; // 要素にクラスを設定
element.textContent = text; // 要素のテキストを設定
history.appendChild(element); // メッセージを追加
return element;;
};
この関数は引数に text と className を持ちます。textはメッセージの内容で、 className はメッセージ要素に適用するクラス名でユーザーのメッセージかChatGPTからの応答かを区別するために使います。
ユーザーのメッセージをChatGPTに送り、応答を取得する関数
// チャットの履歴を保存するための配列
let chatLog = [];
const getAssistantResponse = async (userMsg) => {
・・・・
});
APIにリクエストを送信するため、getAssistantResponse関数をasyncによって非同期関数に設定します。引数にuserMsgを受け取り、チャットの履歴を保存するための配列chatLogを作成します。
ユーザーのメッセージをチャット履歴に追加してAPIにリクエストを送信する
// ユーザーのメッセージをチャット履歴に追加
chatLog.push({ role: 'user', content: userMsg });
// APIにリクエストを送信
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', // POSTリクエスト
headers: {
'Content-Type': 'application/json', // リクエストの内容タイプをJSONに設定
'Authorization': 'Bearer YOUR_API_KEY', // 認証ヘッダーにAPIキーを設定
},
body: JSON.stringify({
model: 'gpt-3.5-turbo', // 使用するモデル
stream: true, // ストリーミングを有効にする
messages: chatLog, // チャット履歴をリクエストボディに含める
}),
});
YOUR_API_KEYにあなたが取得したAPIキーを設定して下さい。stream:true はAPIからの応答をストリーミングモードで受け取るための設定で、これによってAPIは応答全体を一度に返すのではなく、応答を部分的にリアルタイムで送信します。また、messages: chatLog によってチャット履歴をリクエストボディに含めることで、過去のチャット履歴をAPIに送信し、その内容を考慮した応答を生成することができます。
ストリームデータを処理する関数
次にサーバーから送られてくるストリームデータを少しずつ読み込んで、それを整形した文字列に変換する関数を作成します。
const processStream = async (body) => {
const reader = body.getReader(); // ストリームのリーダーを取得
const decoder = new TextDecoder(); // テキストデコーダーを作成
let dataBuffer = ''; // データバッファ用の変数を初期化
// ChatGPTのメッセージを表示する要素を事前に作成
const assistantMessageElement = appendMessage("", "assistant");
・・・
};
getReader()によってreaderオブジェクトを作成します。このreaderを使うことで、サーバーから送られてくるデータを少しずつ読み込むことができます。ここでの引数 bodyにはサーバーから受信したresponse.bodyが入ります。
また、サーバーから送られてくるストリーミングレスポンスをテキストデコーダーを使って処理するためにnew TextDecoder()をインスタンス化して、decoderオブジェクトを作成します。dataBufferは文字列を一時的に保管しておくための変数で、ストリームから読み込んだデータをここに追加していきます。
ストリームからデータを読み込むループの作成
while (true) {
const { value, done } = await reader.read(); // データを読み込む
if (done) break; // 読み込みが完了した場合、ループを終了
// データをデコードしてバッファに追加
dataBuffer += decoder.decode(value, { stream: true });
・・・・
};
ストリームデータをreader.read()によって読み込むためのループを開始します。awaitによって読み込みが完了するまで後続の処理を行わず、1つのストリームデータが取得されるたびにvalueには取得されたストリームデータ、doneにはブール値(すべてのストリームの取得が終わりに到達している場合はture、そうでない場合はfalse)が代入されます。そして、取得すべきストリームデータが残っている限り、ループを継続して取得したストリームデータごとにdecoder.decode()によって文字列にデコードして変数dataBufferに格納します。
続いて変数dataBuffer内のデータを処理します。
取得したデータを切り出して整形する
while (true) {
const newLinePos = dataBuffer.indexOf('\n'); // 改行を探す
if (newLinePos === -1) break; // 改行が見つからない場合、ループを終了
// 改行までのデータを取り出し、トリム
const dataLine = dataBuffer.slice(0, newLinePos).trim();
dataBuffer = dataBuffer.slice(newLinePos + 1); // バッファを更新
・・・・
};
indexOf(‘\n’)によって変数dataBuffer内の改行を探します。indexOf()は検索した対象の文字列がなかった場合は数値の-1を返すため、変数newLinePosが-1だった場合はif文がtrueになりbreakしてループを抜けます。改行が見つかった場合、newLinePosには改行文字 \n
が存在する位置(インデックス番号)が入って後続の処理に続きます。
そのあとslice()によって文字列の最初から改行の存在する位置の前までのデータを切り出してtrim()によって前後の不要な空白を削除して、変数dataLineに代入します。
そして改行までのデータの切り出しが終わると元データを含む変数dataBufferを更新する必要があるため、改行インデックスを表すnewLinePosの次の文字列(+1)から最後までの文字列を残してdataBufferを更新します。
データ行を処理する
次に変数dataLineに格納されたデータ行を処理していきます。
if (dataLine.startsWith('data:')) {
if (dataLine.includes('[DONE]')) { // ストリーミングの終わりを確認
// チャット履歴に追加
chatLog.push({ role: 'assistant', content: assistantMessageElement.textContent });
return;
}
const jsonData = JSON.parse(dataLine.slice(5)); // データ行をJSONとしてパース
if (jsonData.choices && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
const assistantMessage = jsonData.choices[0].delta.content; // ChatGPTの応答を取得
assistantMessageElement.textContent += assistantMessage; // ChatGPTのメッセージを追加
}
}
変数dataLineのデータ行の先頭の‘data:’をslice()によって取り除き、JSON.parse()により残りの文字列を JSON オブジェクトに変換します。このオブジェクトが条件式によってストリーミングにおけるChatGPT APIのレスポンス形式の階層構造であった場合にChatGPTからの応答を取得して、assistantMessageElement.textContentに追加していきます。
また、データ列にストリーミングの終わりを表す‘[DONE]’があった場合は今まで取得したすべてのメッセージをチャットの履歴に追加して処理を終了します。
この一連の while文の中の処理は、サーバーからストリーミングで送られてくるデータをループしながら、一つ一つのデータ行を取り出して処理するというもので、これによりストリームデータをリアルタイムに処理することができます。
フォームの送信イベントに対するハンドラの設定
ユーザーがChatGPTの送信フォームに入力したメッセージを処理するイベントに対するハンドラを設定します。
form.addEventListener("submit", async (event) => {
event.preventDefault(); // デフォルトの送信動作をキャンセル
const userMsg = input.value.trim(); // 入力フィールドの値を取得し、トリム
if (!userMsg) return; // 入力が空でないことを確認
// ユーザーのメッセージを表示する要素を作成し、チャット履歴に追加
appendMessage(userMsg, "user");
chatLog.push({ role: 'user', content: userMsg });
input.value = ""; // 入力フィールドをクリア
input.focus(); // 入力フィールドにフォーカスを戻す
// ChatGPTの応答を取得する関数の実行
await getAssistantResponse(userMsg);
});
フォームが Enter されることによってイベントが発火します。preventDefault()はページのリロードなどのデフォルトの送信動作を防ぎ、if (!userMsg) return;によって入力が空でないことを確認したりとユーザーのメッセージをChatGPTに送り、応答を取得する関数 getAssistantResponseに繋がる動作を設定しています。
ウィンドウを自動的にスクロールする
const autoScroll = () => {
// ウィンドウを最下部にスクロール
msgWindow.scrollTop = msgWindow.scrollHeight;
};
// DOM 変更を監視
const observer = new MutationObserver(autoScroll);
// 新しいノードがウィンドウに追加されたときにスクロール
observer.observe(msgWindow, { childList: true, subtree: true });
scrollTopをscrollHeightに設定することでウィンドウのスクロール位置をコンテンツの全体の高さまで移動させます。これにより最新のメッセージが表示されるようになります。
autoScroll関数をコールバックとしてMutationObserverのインスタンスを作成します。MutationObserverはDOM 要素に対する変更を監視し、変更が発生するとautoScroll関数を実行します。childList: true, subtree: true によって監視対象要素の全ての子孫ノードの変更を監視することを意味します。
これで一連のプログラムを作成することができました。
// HTMLドキュメントが完全に読み込まれたときに実行される関数
document.addEventListener("DOMContentLoaded", () => {
const getElement = (id) => document.getElementById(id);
const form = getElement("message-form");
const input = getElement("message-input");
const history = getElement("message-history");
const msgWindow = getElement("message-window");
// ページを読みこんだときに入力フォームにフォーカスをあてる
input.focus();
// メッセージをウィンドウに追加する関数
const appendMessage = (text, className) => {
const element = document.createElement("p");
element.className = className;
element.textContent = text;
history.appendChild(element);
return element;
};
let chatLog = [];
// ユーザーのメッセージをChatGPTに送り、応答を取得する関数
const getAssistantResponse = async (userMsg) => {
chatLog.push({ role: 'user', content: userMsg });
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
stream: true,
messages: chatLog,
}),
});
// エラーメッセージをコンソールに表示
if (!response.ok) {
throw new Error(await response.text());
}
// ストリームの処理を開始
await processStream(response.body);
};
// ストリームを処理する関数
const processStream = async (body) => {
const reader = body.getReader();
const decoder = new TextDecoder();
let dataBuffer = '';
const assistantMessageElement = appendMessage("", "assistant");
while (true) {
const { value, done } = await reader.read();
if (done) break;
dataBuffer += decoder.decode(value, { stream: true });
while (true) {
const newLinePos = dataBuffer.indexOf('\n');
if (newLinePos === -1) break;
const dataLine = dataBuffer.slice(0, newLinePos).trim();
dataBuffer = dataBuffer.slice(newLinePos + 1);
if (dataLine.startsWith('data:')) {
if (dataLine.includes('[DONE]')) {
chatLog.push({ role: 'assistant', content: assistantMessageElement.textContent });
return;
}
const jsonData = JSON.parse(dataLine.slice(5));
if (jsonData.choices && jsonData.choices[0].delta && jsonData.choices[0].delta.content) {
const assistantMessage = jsonData.choices[0].delta.content;
assistantMessageElement.textContent += assistantMessage;
}
}
}
}
};
// フォームの送信イベントに対するハンドラ
form.addEventListener("submit", async (event) => {
event.preventDefault();
const userMsg = input.value.trim();
if (!userMsg) return;
appendMessage(userMsg, "user");
chatLog.push({ role: 'user', content: userMsg });
input.value = "";
input.focus();
await getAssistantResponse(userMsg);
});
// ウィンドウを自動的にスクロールする
const autoScroll = () => {
msgWindow.scrollTop = msgWindow.scrollHeight;
};
const observer = new MutationObserver(autoScroll);
observer.observe(msgWindow, { childList: true, subtree: true })
});
おわりに
おわりにサンプル動画で使用したCSSを記載しておきます。参考にして頂けると幸いです。最後までご覧頂きありがとうございました。
body {
font-family: "Zen Kurenaido", sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
font-weight: 500;
font-style: normal;
min-height: 100vh;
background-color: #f5f5f5;
}
img{
position:fixed;
top:400px;
left:1000px;
}
/* メッセージウィンドウのスタイル */
#message-container{
position: relative;
}
#message-window {
width: 600px;
max-height: 80vh;
background-color: #006633;
border-radius: 8px;
overflow-y: auto;
box-shadow: 0 0 5px #333, 0 0 5px #555 inset;
border: 5px solid #c0c0c0;
padding: 20px;
margin-bottom: 20px;
box-sizing: border-box;
}
/* チョークのスタイル */
#chalk-pink,#chalk-white {
position: absolute;
bottom: 25px;
height: 5px;
border-radius: 3px;
}
#chalk-pink{
right: 70px;
width: 30px;
background-color: #ff42a0;
}
#chalk-white {
right: 120px;
width: 30px;
background-color: #fff;
}
/* メッセージフォームのスタイル */
#message-form {
position: fixed;
bottom: 20px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
#message-input {
width: calc(33.33% - 20px);
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
}
/* メッセージのスタイル */
.user {
color: #ff42a0;
}
.assistant {
color: #fff;
}