見出し画像

英語学習補助のChatGPTクローンアプリみたいなのを作った

こんにちは、奏守です。

今回は表題の通り、英語学習補助のChatGPTクローンアプリみたいなのを作ったという内容です。
一番下にコード全文(API key, プロンプトの一部を除く)を載せています。
htmlファイルにコピペして、ブラウザで開けばPC、スマホ両方で使えます。
使用後もブラウザの機能でページ保存したhtmlファイルを開けば前回の続きからチャットができます。

サーバーを介しておらず、ローカルでの個人利用を前提としているので、使用する場合はAPI keyの取り扱いに注意してください。

具体的な機能について、ユーザーがプロンプトを入力すると、
・プロンプトの英語訳
・英語の回答
・日本語の回答
が表示されます。
また、ボタンが2つ用意してあり、
・Sendボタンは通常の文脈を考慮したチャット
・Explainボタンは翻訳無し、文脈無しで入力したテキストを解説
することができます。

自分の聞きたいこと話したいことが常に英語訳されるのを読んでいたら、英語のアウトプットの学習になりそう、といった考えです。

始めはChatGPTのCustom InstructionやGPTsでできないか試していたのですが、安定しなかったり、読みづらかったので、GPT APIで自作することにしました。

Pythonで動かすだけならすぐできるのですが、スマホとPC両方で見やすくしたかったのでWebアプリに挑戦しました。
今回初めてJavascriptに触れ、2日で作ったので、おかしいところはあると思います。


コードの一部について簡単な解説

ローディングGIF

自分が使っているときは「素材置き場(AI生成)」という自分の記事の画像を呼び出しています。DALL-E3で生成した画像に軽く手を加えてGIFにしました。
ローカルから呼び出す予定だったのですが、AndroidのChromeではローカルファイルにアクセスできませんでした。ちなみにPCではfirefox使ってるのですが、Androidのfirefoxでhtmlファイル開けなかったのでChrome使ってる、何故?

        // ローディング画像を表示・非表示にする関数
        function showLoading(show, chatBox) {
            let loadingDiv = chatBox.querySelector('.loading');
            if (show) {
                if (!loadingDiv) {
                    loadingDiv = document.createElement('div');
                    loadingDiv.classList.add('chat-message', 'loading');
                    loadingDiv.innerHTML = '<img src="~ローディング画像のパス(またはURL)をここに入力する。~" alt="Loading...">'; // ローディング画像のパスを指定
                    chatBox.prepend(loadingDiv);
                }
            } else {
                if (loadingDiv) {
                    chatBox.removeChild(loadingDiv);
                }
            }
        }

GPT API呼び出しの関数

API key, model, temperature, messagesが引数です。翻訳ではgpt-3.5-turbo, temperature = 0で、翻訳ではgpt-4, temperature = 1としています。
fetch()でリクエストを送っています。

        // GPT API呼び出し関数を定義
        function callGPTAPI(apiKey, model, temperature, messages) {
            return fetch('https://api.openai.com/v1/chat/completions', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${apiKey}`
                },
                body: JSON.stringify({
                    model: model,
                    messages: messages,
                    temperature: temperature,
                    max_tokens: 3000,
                    top_p: 1,
                    frequency_penalty: 0,
                    presence_penalty: 0,
                })
            })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            });
        }

SendボタンとExplainボタン

Ctrl + EnterをSendボタン、Shift + EnterをExplainボタンにショートカットを割り当てています。
Enterは改行できます。

        document.getElementById('userInput').addEventListener('keydown', function(event) {
            // Ctrl+Enter で送信、Enter のみで改行
            if (event.key === 'Enter') {
                if (event.ctrlKey) {
                    event.preventDefault(); // デフォルトの改行を防ぐ
                    sendMessage(); // 送信処理を実行
                } else if (event.shiftKey) {
                    event.preventDefault(); // デフォルトの改行を防ぐ
                    explainMessage(); // 説明処理を実行
                }
                // Ctrl キーが押されていない場合は何もしない(デフォルトの改行を許可)
            }
        });

ユーザープロンプトを翻訳する際のプロンプト

gpt-3.5-turboを使っているので、System promptに翻訳指示を書くだけだと、ユーザーのプロンプトに回答してしまう事が多かったので、ユーザープロンプトに翻訳指示を含めています。もっといいプロンプトがあるかもしれません。

            // 翻訳者(GPT API)を呼び出し
            callGPTAPI(apiKey, "gpt-3.5-turbo-1106", 0, [
                // 翻訳失敗が多かったプロンプト{"role": "system", "content": "You are an English translator. Return the English translations of all user's prompts"},
                {"role": "system", "content": "You are an English translator."},
                {"role": "user", "content": "Please translate the following sentences into English."},
                {"role": "user", "content": userInput}
            ])

会話履歴の使用

chat_historyリストに英語のプロンプトと英語の回答が登録されていき、コンテキストとしてプロンプトに追加されます。
取り敢えず最新2回のやり取りをコンテキストに含めています。
Explainボタンでは会話履歴を使用していないので、会話を途切れさせずに解説させることが可能です。
また、ブラウザの機能でページ保存したhtmlファイルを開いた場合に前回の続きからチャットできるようにしてます。
書いてる途中で気づいたのですが、最新のやり取りがExplainの状態で保存したhtmlファイルを開くと日本語の解説文がコンテキストに入るため、通常の会話でも日本語で返答するようになってしまいます。これは会話に「英語で返答して」と一度含めれば以降は正常に戻るので修正してないです。

        // 会話履歴リストを作成
        var chat_history = [];

        // 最新の会話2セットを取得
        const last_translated_user_messages = Array.from(document.querySelectorAll('.translated-user-message')).map(element => element.textContent).slice(0, 2);
        const last_gpt_responses = Array.from(document.querySelectorAll('.gpt-response')).map(element => element.textContent).slice(0, 2);

        // 会話履歴リストに登録
        for (let i = last_translated_user_messages.length - 1; i >= 0; i--){
            chat_history.push({"role": "user", "content": last_translated_user_messages[i].replace(/\n/g, '')});
            chat_history.push({"role": "assistant", "content": last_gpt_responses[i].replace(/\n/g, '')});
        }

コード全文

API key, 一部のSystem promptを書き込んだあと、htmlファイルにコピペしてブラウザで開けば使えます。書き足す必要がある部分は「~」を検索すれば見つけやすいと思います。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AIChat_with_Translator</title>
<style>
    body {
        font-family: Arial, sans-serif;
        margin: 0;
        padding: 0;
        background-color: #f2f2f2;
    }
    .chat-container {
        width: 100%;
        max-width: 600px;
        margin: auto;
        padding: 20px;
    }
    .chat-box {
        background-color: white;
        border-radius: 15px;
        padding: 10px;
        margin-bottom: 80px;
        display: flex;
        flex-direction: column-reverse; /* 新しいメッセージを下に表示 */
    }
    .chat-message {
        padding: 10px;
        border-radius: 10px;
        margin: 5px 0;
        max-width: 80%;
        word-wrap: break-word; /* 長いメッセージを折り返し */
    }
    .user-message {
        background-color: #dbffed;
        align-self: flex-end;
        margin-left: 20%;
    }
    .gpt-response {
        background-color: #ffdbed;
        align-self: flex-start;
        margin-right: 20%;
    }
    .translated-user-message {
        background-color: #dbffff;
        align-self: flex-end;
        margin-left: 20%;
    }
    .translated-gpt-response {
        background-color: #ffdbdb;
        align-self: flex-start;
        margin-right: 20%;
    }
    .input-area {
        position: fixed; /* 入力エリアを固定位置に配置 */
        bottom: 0; /* 画面の下端に配置 */
        left: 50%; /* 画面の中央に配置 */
        transform: translateX(-50%); /* 中央揃えのために左に50%移動 */
        max-width: 600px; /* chat-boxと同じ最大幅 */
        width: calc(100% - 40px); /* 全体の幅から両サイドのpaddingを引いた幅 */
        margin: auto; /* 中央揃え */
        background-color: white;
        display: flex;
        padding: 10px;
        box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2); /* 上部に影をつける */
    }
    .input-area textarea {
        flex-grow: 1;
        padding: 10px;
        border: none;
        border-radius: 10px;
        margin-right: 10px;
        resize: none; /* ユーザーによるリサイズを無効にする */
        overflow: auto; /* 長文入力時にスクロールバーを表示 */
        line-height: 1.5; /* 行の高さ */
        height: 1.5em; /* 初期の高さを1行分に設定 */
        max-height: calc(1.5em * 8); /* 8行分の最大高さ */
    }
    .input-area .buttons {
        display: flex;
        flex-direction: column; /* ボタンを縦に並べる */
        gap: 5px; /* ボタン間に5pxの間隔を開ける */
    }
    .input-area button {
        flex: 1; /* 同じサイズでボタンを分割 */
        padding: 10px 15px;
        border: none;
        border-radius: 10px;
        background-color: #009688;
        color: white;
        cursor: pointer;
    }
    .input-area button:hover {
        background-color: #00796b;
    }
    .user-label {
        font-weight: bold;
        margin-bottom: 2px;
        align-self: flex-end;
        margin-left: 20%;
        font-size: small;
    }
    .gpt-label {
        font-weight: bold;
        margin-bottom: 2px;
        align-self: flex-start;
        margin-right: 20%;
        font-size: small;
    }
    .loading img {
        width: 75px; /* 画像の幅 */
        height: 75px; /* 画像の高さ */
    }
    #scrollToBottomButton {
        position: fixed; /* ボタンを固定位置に配置 */
        top: 50%; /* 画面の上から50%の位置 */
        right: 10px; /* 画面の右から10pxの位置 */
        transform: translateY(-50%); /* Y軸方向に50%移動して中央に配置 */
        padding: 5px 10px; /* パディング */
        font-size: 16px; /* フォントサイズ */
        background-color: transparent; /* 背景色を透明に */
        color: gray; /* 文字色を灰色に */
        border: 1px solid gray; /* 灰色の枠線 */
        border-radius: 5px; /* 角の丸み */
        cursor: pointer; /* カーソルをポインターに */
    }
    #scrollToBottomButton:hover {
        color: darkgray; /* ホバー時の文字色 */
        border-color: darkgray; /* ホバー時の枠線の色 */
    }
</style>

</head>
<body>
    <div class="chat-container">
        <div id="chatBox" class="chat-box"></div>
        <div class="input-area">
            <textarea id="userInput" placeholder="Type a message..."></textarea>
            <div class="buttons">
                <button id="explainButton">Explain</button>
                <button id="submitButton">Send</button>
            </div>
        </div>        
    </div>

    <!-- スクロールボタンを追加 -->
    <button id="scrollToBottomButton">↓</button>

    <!-- ローディング画像をHTMLに追加 -->
    <img id="loadingImage" src="loading.gif" style="display: none; width: 50px; height: 50px;" />

    <script>
        // スクロールボタン
        document.getElementById('scrollToBottomButton').addEventListener('click', function() {
            window.scrollTo(0, document.body.scrollHeight);
        });

        // ローディング画像を表示・非表示にする関数
        function showLoading(show, chatBox) {
            let loadingDiv = chatBox.querySelector('.loading');
            if (show) {
                if (!loadingDiv) {
                    loadingDiv = document.createElement('div');
                    loadingDiv.classList.add('chat-message', 'loading');
                    loadingDiv.innerHTML = '<img src="~ローディング画像のパス(またはURL)をここに入力する。~" alt="Loading...">'; // ローディング画像のパスを指定
                    chatBox.prepend(loadingDiv);
                }
            } else {
                if (loadingDiv) {
                    chatBox.removeChild(loadingDiv);
                }
            }
        }

        // GPT API呼び出し関数を定義
        function callGPTAPI(apiKey, model, temperature, messages) {
            return fetch('https://api.openai.com/v1/chat/completions', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${apiKey}`
                },
                body: JSON.stringify({
                    model: model,
                    messages: messages,
                    temperature: temperature,
                    max_tokens: 3000,
                    top_p: 1,
                    frequency_penalty: 0,
                    presence_penalty: 0,
                })
            })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            });
        }
        
        // テキストエリアの大きさを調整する関数
        function adjustTextareaHeight() {
            const textarea = document.getElementById('userInput');
            textarea.style.height = 'auto'; // 一度高さをリセット
            const maxHeight = parseInt(window.getComputedStyle(textarea).maxHeight, 10); // 最大高さを取得

            // スクロール高さを使って実際の高さを設定(最大高さを超えないように)
            textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + 'px';
        }

        document.getElementById('userInput').addEventListener('input', adjustTextareaHeight);
        document.getElementById('userInput').addEventListener('keydown', function(event) {
            // Ctrl+Enter で送信、Enter のみで改行
            if (event.key === 'Enter') {
                if (event.ctrlKey) {
                    event.preventDefault(); // デフォルトの改行を防ぐ
                    sendMessage(); // 送信処理を実行
                } else if (event.shiftKey) {
                    event.preventDefault(); // デフォルトの改行を防ぐ
                    explainMessage(); // 説明処理を実行
                }
                // Ctrl キーが押されていない場合は何もしない(デフォルトの改行を許可)
            }
        });

        // 初期ロード時のテキストエリアの高さ調整
        window.addEventListener('load', adjustTextareaHeight);

        // 通常の会話の関数
        function sendMessage() {
            const userInput = document.getElementById('userInput').value;
            if (userInput.trim() === '') return; // 空のメッセージは無視
            const chatBox = document.getElementById('chatBox');
            
            // ユーザーのメッセージの上に「User」と表示
            const userLabel = document.createElement('div');
            userLabel.textContent = "User";
            userLabel.classList.add('user-label');
            chatBox.prepend(userLabel);
            
            // ユーザーのメッセージを表示
            const userDiv = document.createElement('div');
            userDiv.classList.add('chat-message', 'user-message');
            userDiv.innerHTML = userInput.replace(/\n/g, '<br>'); // 改行を <br> に置換
            chatBox.prepend(userDiv);

            // 翻訳者(GPT API)を呼び出し
            callGPTAPI(apiKey, "gpt-3.5-turbo-1106", 0, [
                //{"role": "system", "content": "You are an English translator. Return the English translations of all user's prompts"},
                {"role": "system", "content": "You are an English translator."},
                {"role": "user", "content": "Please translate the following sentences into English."},
                {"role": "user", "content": userInput}
            ])
            .then(data => {
                if (data.choices && data.choices.length > 0) {
                    // 翻訳結果を表示
                    const translatedText = data.choices[0].message.content;
                    const translatorDiv = document.createElement('div');
                    translatorDiv.classList.add('chat-message', 'translated-user-message');
                    translatorDiv.innerHTML = translatedText.replace(/\n/g, '<br>'); // 改行を <br> に置換
                    chatBox.prepend(translatorDiv);

                    // 会話履歴リストに登録
                    chat_history.push({"role": "user", "content": translatedText});

                    // 回答者(GPT API)のメッセージの上に「Imo-to AI」と表示
                    const gptLabel = document.createElement('div');
                    gptLabel.textContent = "Imo-to AI";
                    gptLabel.classList.add('gpt-label');
                    chatBox.prepend(gptLabel);

                    // APIを呼び出す前にローディング画像を表示
                    showLoading(true, chatBox);

                    // プロンプト全体が適切か確認
                    const prompt = [
                        {"role": "system", "content": "~キャラ設定をここに入力する~"}
                        ].concat(chat_history)
                    console.log(prompt);

                    // 回答者(GPT API)を呼び出し
                    return callGPTAPI(apiKey, "gpt-4-1106-preview", 1, prompt);
                } else {
                    throw new Error("Translation failed.");
                }
            })
            .then(data => {
                // レスポンスが返ってきたらローディング画像を非表示にする
                showLoading(false, chatBox);
                if (data.choices && data.choices.length > 0) {
                    // 回答を表示
                    const gptResponse = data.choices[0].message.content;
                    const responderDiv = document.createElement('div');
                    responderDiv.classList.add('chat-message', 'gpt-response');
                    responderDiv.innerHTML = gptResponse.replace(/\n/g, '<br>'); // 改行を <br> に置換
                    chatBox.prepend(responderDiv);

                    // 会話履歴リストに登録
                    chat_history.push({"role": "assistant", "content": gptResponse});
                    if (chat_history.length >= 5){
                        chat_history.splice(0, 2);
                    };

                    // 日本語翻訳者(GPT API)を呼び出し
                    return callGPTAPI(apiKey, "gpt-3.5-turbo-1106", 0, [
                        {"role": "system", "content": "You are a Japanese translator. Please translate the user's prompt into Japanese."},
                        {"role": "user", "content": gptResponse}
                    ]);
                } else {
                    throw new Error("Response generation failed.");
                }
            })
            .then(data => {
                if (data.choices && data.choices.length > 0) {
                    // 翻訳結果を表示
                    const translationDiv = document.createElement('div');
                    translationDiv.classList.add('chat-message', 'translated-gpt-response');
                    translationDiv.innerHTML = data.choices[0].message.content.replace(/\n/g, '<br>'); // 改行を <br> に置換
                    chatBox.prepend(translationDiv);
                } else {
                    chatBox.innerHTML += "<div class='chat-message translated-gpt-response'>Sorry, couldn't translate the response.</div>";
                }
            })
            .catch(error => {
                // エラーがあった場合もローディング画像を非表示にする
                showLoading(false, chatBox);
                console.error('Error:', error);
                chatBox.innerHTML += "<div class='chat-message gpt-response'>An error occurred.</div>";
            });

            document.getElementById('userInput').value = ''; // Clear input
            adjustTextareaHeight(); // 高さを再調整
        }

        // 解説してもらう関数
        function explainMessage() {
            const userInput = document.getElementById('userInput').value;
            if (userInput.trim() === '') return; // 空のメッセージは無視
            const chatBox = document.getElementById('chatBox');

            // ユーザーのメッセージの上に「User」と表示
            const userLabel = document.createElement('div');
            userLabel.textContent = "User";
            userLabel.classList.add('user-label');
            chatBox.prepend(userLabel);
            
            // ユーザーのメッセージを表示
            const userDiv = document.createElement('div');
            userDiv.classList.add('chat-message', 'user-message');
            userDiv.innerHTML = userInput.replace(/\n/g, '<br>'); // 改行を <br> に置換
            chatBox.prepend(userDiv);
            
            // 解説者(GPT API)のメッセージの上に「Imo-to AI」と表示
            const gptLabel = document.createElement('div');
            gptLabel.textContent = "Imo-to AI";
            gptLabel.classList.add('gpt-label');
            chatBox.prepend(gptLabel);

            // APIを呼び出す前にローディング画像を表示
            showLoading(true, chatBox);

            // プロンプト全体が適切か確認
            const prompt = [
                {"role": "system", "content": "~キャラ設定をここに入力する~"},
                {"role": "user", "content": "次のテキストを解説して。"},
                {"role": "user", "content": userInput}
            ];
            console.log(prompt);

            // 解説者(GPT API)を呼び出し
            callGPTAPI(apiKey, "gpt-4-1106-preview", 1, prompt)
            .then(data => {
                // レスポンスが返ってきたらローディング画像を非表示にする
                showLoading(false, chatBox);
                if (data.choices && data.choices.length > 0) {
                    // 回答を表示
                    const explanatoryText = data.choices[0].message.content;
                    const explainDiv = document.createElement('div');
                    explainDiv.classList.add('chat-message', 'gpt-response');
                    explainDiv.innerHTML = explanatoryText.replace(/\n/g, '<br>'); // 改行を <br> に置換
                    chatBox.prepend(explainDiv);
                } else {
                    throw new Error("Explanatory text generation failed.");
                }
            })
            .catch(error => {
                // エラーがあった場合もローディング画像を非表示にする
                showLoading(false, chatBox);
                console.error('Error:', error);
                chatBox.innerHTML += "<div class='chat-message gpt-response'>An error occurred.</div>";
            });

            document.getElementById('userInput').value = ''; // Clear input
            adjustTextareaHeight(); // 高さを再調整
        }

        // 会話履歴リストを作成
        var chat_history = [];

        // 最新の会話2セットを取得
        const last_translated_user_messages = Array.from(document.querySelectorAll('.translated-user-message')).map(element => element.textContent).slice(0, 2);
        const last_gpt_responses = Array.from(document.querySelectorAll('.gpt-response')).map(element => element.textContent).slice(0, 2);

        // 会話履歴リストに登録
        for (let i = last_translated_user_messages.length - 1; i >= 0; i--){
            chat_history.push({"role": "user", "content": last_translated_user_messages[i].replace(/\n/g, '')});
            chat_history.push({"role": "assistant", "content": last_gpt_responses[i].replace(/\n/g, '')});
        }

        const apiKey = '~API keyをここに入力する~'; // APIキーを入力
        document.getElementById('submitButton').addEventListener('click', sendMessage);
        document.getElementById('explainButton').addEventListener('click', explainMessage);
    </script>
</body>
</html>

終わりに

自分なりに使いやすいチャットアプリが作れたので満足してます。想定していたより英語を読んだり表現の勉強になって良い感じです。
最初はティラノストーリーというチャット形式のノベルゲーム制作ソフト(ティラノスクリプトの派生)を使ってアプリにしようとしたのですが、APIリクエストでエラーが発生して作れませんでした。この失敗談もそのうち記事にするかもです。
今回、初めてhtmlやJavaScriptに触れてみて、PythonやMatlabに比べて複雑ですがGUIを弄れるのは面白かったです。そのうちゲームとかも作ってみたいですね。

金髪要素が無いと思いきや動画で金髪について解説させてます()。

ここまで読んでいただきありがとうございました。

この記事が気に入ったらサポートをしてみませんか?