AIと一緒に小説を書く旅(4)

前回のあらすじ

 AIと一緒に小説を書く。そのためには試行回数がめちゃくちゃ増えそうなのでツールを作ろう…とか言って作り始めたらゴールデンウィークが終わろうとしているのであった/(^o^)\オロカモノッ

(中略)ツール完成しました

 昨日の夜(5/5)気付いたんですけど、自分のツール開発速度=プログラミング速度が遅いというのもあるのですが、このブログにしょーもない進捗を毎回書いているというのがかなりの遅延要因を占めていました。というわけで、今回は途中経過はぜんぶ省きます。なんやかんやあって完成しました。ハイッ

ハイッ(‘_’)

 え~~~~これはですね~~~~、GPTを3つバラバラにブラウザ上で動かすためのツールです。もともとの構想は下図みたいな感じを想定していました。この図のうち、左のキャラクターくんと、真ん中の配信くんと、右のライターくんをAIにしようと思っていました。が、真ん中の配信くんを作るのがクソ難しそうだったので、今回はキャラクター2人+ライターくんの3つ分だけAIにします。そういうわけでGPTを3つ動かせるようにしています。(配信くんは人力でやります)

概念…
  • 仕様
    • 左/中/右で、3つのインスタンスを動かします。お互いに履歴情報および設定情報は独立しています。
    • ブラウザをリロードするとデータはリセットされて初期値に戻ります。
    • モデルはgpt-3.5-turboとgpt-4から選択できます。
    • ランダム性(Temperature)は0.1単位で0~2まで変更できます。
    • 応答データの数は1~5まで変更できます。
    • チャット履歴機能(system、user、assistant)の編集に対応しています。
      • 「追加」ボタンで履歴が1つ増えます。
      • 「削除」ボタンで履歴が1つ消えます。
      • system、user、assistantはindexで固定で決まります。
        • 最初の履歴がsystem
        • 以降はuserとassistantが交互に続きます
    • 「実行」ボタンを押下すると、履歴情報・設定情報に従ってAIにプロンプトを投げます。応答は一番下のテキストボックスに入ります。

 構造は超単純です。ブラウザ上で入力した内容をボタンクリックを契機にしてjavascriptからPythonにHTTP POSTして、Python側でOpenAIのAPIを呼んで、APIの応答結果をHTTP POSTの応答にして返して、最後にjavascriptで応答内容をブラウザのHTMLに反映する、それだけです。

 実際に動かしてみたところです。なんか変なボーダーが入っているのは、各要素の境界がどこにあるかよくわからなくてclassごとに違う色を引いたためです(脳筋printf思想)。

うごけばヨシッ

 このショボショボツールを作るのにGWをほぼ全ツッパしてしまった…恐ろしや(’A`) でもいいんだ、これで試行回数たくさんできるから…あと結果的にだけどコレ作っている間にGPT-4のAPIウィッシュリストが通ったから(´∀`*) ありがとう…ありがとうッ

 コードとりあえず貼っておきます。クソ汚いコードです。コメントなし。APIキーは抜いています。まじで…クソ汚いコードです。ただでさえ汚いのにシンタックスハイライトのないWordPressに貼り付けているのでもぉマジでエライコッチャです。見にくさ限界突破です。

 あとは上の動画撮ったあとにちょっと修正したので微妙に違うとこがあります。結果表示部分をtextareaではなくdivにしました。改行のbrタグを反映する必要があったためです。

階層構造
/
  static/
    script.js
    styles.css
  templetes/
    index.html
  app.py
app.py
import os
import openai
from flask import Flask, render_template, request, jsonify
os.environ["OPENAI_API_KEY"] = "XXX"
openai.api_key = "XXX"

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/process", methods=["POST"])
def process():
    data = request.json["data"]
    temperature = request.json["temperature"]
    result_n = request.json["result_n"]    
    model = request.json["model"]    
    chat_log = []

    for i in range(len(data)):
        label = "system"
        if i == 0:
            label = "system"
        elif i % 2 == 1:
            label = "user"
        else:
            label = "assistant"
        chat_log.append({"role": f"{label}", "content": f"""{data[i]}"""})

    response = openai.ChatCompletion.create(model = model, messages = chat_log, n = int(result_n), temperature = float(temperature))

    messages = []
    finish_reason = []
    for choice in response['choices']:
        messages.append(choice['message']['content'])
        finish_reason.append(choice['finish_reason'])

    prompt_tokens = response["usage"]["prompt_tokens"]
    completion_tokens = response["usage"]["completion_tokens"]

    processed_data = [d.upper() for d in messages]
    return jsonify({"result": processed_data})

if __name__ == "__main__":
    app.run(debug=True)
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>にせチャット</title>
    <link rel="stylesheet" href="/static/styles.css">
    <script src="/static/script.js" defer></script>
</head>
<body>
    <div id="loader"></div>
    <div class="box-parent" id="box-parent"></div>
    <script>
        const parentElement = document.getElementById('box-parent');

        for (let i = 1; i <= 3; i++) {
            const boxChird = document.createElement('div');
            boxChird.className = 'box-chird';

            const boxChird1 = document.createElement('div');
            boxChird1.className = 'box-chird1';
            boxChird1.id = `parentElementId-0${i}`;
            boxChird1.setAttribute('charaindex', i);

            const boxChird1Content = document.createElement('div');
            boxChird1Content.className = 'box-chird1';

            const htmlContent = `
                <div class="box-chird2">
                    <div id="TbStatusView-0${i}" class="resultArea"></div>
                </div>
                <div class="box-chird2">
                    <button id="btnAdd-0${i}">追加</button>
                    <button id="btnProc-0${i}">実行</button>
                </div>
                <div class="box-chird2">
                    <label>temp:</label>
                    <input type="number" id="temperature-0${i}" step="0.1" min="0" max="2" value="0">
                </div>
                <div class="box-chird2">
                    <label>n:</label>
                    <input type="number" id="result_n-0${i}" step="1" min="1" max="5" value="1">
                </div>
                <div class="box-chird2">
                    <label for="model-select-0${i}">model:</label>
                    <select id="model-select-0${i}">
                        <option value="gpt-3.5-turbo" selected>gpt-3.5-turbo</option>
                        <option value="gpt-4">gpt-4</option>
                    </select>
                </div>
            `;
            boxChird1Content.innerHTML = htmlContent;

            boxChird.appendChild(boxChird1);
            boxChird.appendChild(boxChird1Content);
            parentElement.appendChild(boxChird);
        }
    </script>
</body>
</html>
script.js
let stored_texts = [];

window.addEventListener("DOMContentLoaded", () => {
    loader.style.display = "none";
    stored_texts.push([]); // dummy

    for (var charactorIndex = 1; charactorIndex <= 3; ++charactorIndex) {
        stored_texts.push([]);
        const parentElement = document.getElementById("parentElementId-0" + charactorIndex);

        for (let j = 0; j < 2; j++) {
            const newContainer = createNewTextbox(j, charactorIndex);
            addTextboxToDOM(newContainer, parentElement);
        }

        document.getElementById("btnAdd-0" + charactorIndex).addEventListener("click", () => {
            onAddButtonClick(parentElement);
        });
        document.getElementById("btnProc-0" + charactorIndex).addEventListener("click", async () => {
            onProcButtonClick(parentElement);
        });

    }
});


function getLabelNameFromIndex(index) {
    if (index === 0) {
        return "system:";
    } else if (index % 2 === 1) {
        return "user:";
    } else {
        return "assistant:";
    }
}

function onTextboxChange(index, textbox, charactorIndex) {
    stored_texts[charactorIndex][index] = textbox.value;
}

function onAddButtonClick(container) {
    const charactorIndex = container.getAttribute("charaindex");
    const parentElement = document.getElementById("parentElementId-0" + charactorIndex);
    const newContainer = createNewTextbox(container.childElementCount, parseInt(charactorIndex));
    addTextboxToDOM(newContainer , parentElement);
}

function onProcButtonClick(container) {
    const charactorIndex = container.getAttribute("charaindex");
    const TbStatusView = document.getElementById("TbStatusView-0" + charactorIndex);
    const temperature = document.getElementById("temperature-0" + charactorIndex);
    const result_n = document.getElementById("result_n-0" + charactorIndex);
    const selectElement = document.getElementById("model-select-0" + charactorIndex);

    const loader = document.getElementById("loader");
    loader.style.display = "block";

    fetch("/process", {
      method: "POST",
      body: JSON.stringify({data: stored_texts[parseInt(charactorIndex)], temperature: temperature.value, result_n: result_n.value, model: selectElement.value}),
      headers: {
        "Content-Type": "application/json"
      }
    })
    .then(response => response.json())
    .then(data => {
      TbStatusView.innerHTML = JSON.stringify(data.result, null, 2).replace(/\\n/g, "<br>").replace(/ /g, "&nbsp;");
      loader.style.display = "none";
    })
    .catch(error => {
        console.error(error);
        loader.style.display = "none";
    });

}
function onDeleteButtonClick(container, charactorIndex) {

    const index = parseInt(container.getAttribute("data-index"));
    container.remove();
    stored_texts[charactorIndex].splice(index, 1);

    const parentElement = document.getElementById("parentElementId");
    for (let i = index; i < parentElement.children.length; i++) {
        const childContainer = parentElement.children[i];
        const newIndex = parseInt(childContainer.getAttribute("data-index")) - 1;
        childContainer.setAttribute("data-index", newIndex);

        const label = childContainer.querySelector("label");
        label.textContent = getLabelNameFromIndex(newIndex);
    }

}

function createNewTextbox(index, charactorIndex) {
    const container = document.createElement("div");
    const label = document.createElement("label");
    const textbox = document.createElement("textarea");
    const deleteButton = document.createElement("button");

    stored_texts[charactorIndex].push("");

    label.textContent = getLabelNameFromIndex(index);
    label.htmlFor = "textbox-" + index;

    textbox.type = "text";
    textbox.readOnly = false;
    deleteButton.textContent = "削除";
    deleteButton.addEventListener("click", () => {
        onDeleteButtonClick(container, charactorIndex);
    });

    textbox.addEventListener("change", () => {
        onTextboxChange(index, textbox, charactorIndex);
    });

    container.setAttribute("data-index", index);
    container.appendChild(label);
    container.appendChild(textbox);
    container.appendChild(deleteButton);

    return container;
}

function addTextboxToDOM(container, parentElement) {
    parentElement.appendChild(container);
}
styles.css
body {
    font-family: Arial, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
}

textarea, button, input, select, label, div {
    font-size: 12px;
}

textarea {
    width: 280px;
    height: 5em;
    resize: none;
}

.resultArea {
    width: 280px;
    height: auto;
    resize: none;
    background: white;
    text-align: left;
    white-space: pre-wrap;
    word-wrap: break-word;
}

input, select {
    width: 150px;
    height: auto;
    resize: none;
    padding: 0;
    box-sizing: border-box;
}

label {
    display: inline-block;
    width: 50px;
    text-align: right;
}

textarea[type="text"] {
    flex: 1;
}

button {
    cursor: pointer;
}

.container {
    display: flex;
    align-items: center;
}

.box-parent {
    width: 100%;
    border: solid 1px red;
    box-sizing: border-box;
}

.box-chird {
    width: 33%;
    border: solid 1px blue;
    box-sizing: border-box;
    float: left;
    background: lightgray;
}

.box-chird1 {
    border: solid 1px yellow;
    box-sizing: border-box;
    align-items: center;
    justify-content: center;
}
.box-chird2 {
    display: flex;
    border: solid 1px pink;
    box-sizing: border-box;
    align-items: center;
    justify-content: center;
}

#loader {
    border: 16px solid #f3f3f3;
    border-top: 16px solid #3498db;
    border-radius: 50%;
    width: 120px;
    height: 120px;
    animation: spin 2s linear infinite;
    position: fixed;
    top: 50%;
    left: 50%;
    margin-top: -60px;
    margin-left: -60px;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

 そういうわけでツールを作り終わったので、小説を書くフェーズに進もうと思います。がんばるぞ~\(^o^)/

 旅は終わらないッ