OpenAIのAPIを利用したWEBアプリケーションで、ご存じの方もいるかと思いますが…チャットモンスターバトル(チャトモン)というCDLE名古屋が広く展開しているコンテンツがあります。
このコンテンツは私が作成しましたが、フロントエンドと呼ばれるブラウザ上で動作するHTMLとJavaScriptのみで作られています。
仕組みについては下記の記事を参照して下さい。
この方法は非常にシンプルな作りとなっており、
「アッという間にWebアプリが完成出来てしまう非常に強力な方法!」
…でした。
確かに、シンプルで良いのですが、この仕組みはいくつかの問題を抱えています。
この問題について、以前RPACommunityのリアルイベントでの発表資料にも書かせていただきました。
少し前にChatGPT Nightというイベントで「ChatGPT(API)とカメラを使って遊ぶ」という発表を…さらには生成AI EXPO in 東海というイベントでは「AIモンスター人相占い」を公開いたしました。ただ、これらコンテンツの仕組みについては、まだ記事が書けていない現状があります。
なぜか?
前述のコンテンツの仕組みを考えた場合、まず前提として話しておかなければならない…どうしても避けては通れない話題があるからになります。
それは…
「バックエンドの処理」(WEBアプリケーションサーバー)の構築!!
になります。
本来、Webアプリケーションはフロントエンドのみで構築が出来るものではなく、フロントエンドとバックエンドの両方が連携して初めて動作するものです。むしろ、フロントエンドのみで構築してしまう方法が特殊かつ異常なのです。
なら、最初からフロントエンドとバックエンドを連携させた仕組みで構築すれば良かったじゃん!と思うかもしれません。しかし、このフロントエンドとバックエンドを利用したWebアプリケーションの仕組みですが…下記の参考記事を読むと感じられると思いますが…非常に難解かつ面倒な印象を多くの人に与えます。
【参考記事】
さらに、大規模なシステム開発やインターネットに公開するサービスを考えた場合、非常に難しく面倒なものになります。そういう意味では、難しく作ろうと思えばいくらでも難しくなり…面倒になります。
でも、皆さん難しくて面倒なのは嫌ですよね?
私はこの難しさについて…本質以外の工程が非常に多いためだと感じており…どこまでそれをどれだけ削ぎ落とし簡単に出来るのか?を考えてしまいます。
そして、本質が理解出来れば、あとは、必要に応じて難しい要素を後付けで覚えていけば良いとも思っています。
しかし、真面目な方は「いずれ大きなサービスに発展出来るような仕組みにしたい」とか、「基礎をしっかり勉強するために、全機能を勉強してから実装しよう」とか考えがちです。本屋で技術書籍を買ってきて1ページ目から順に読みはじめたり…ですね…
このやり方だと、初めのうちはやる気満々でも…そのうち嫌になって挫折しそうです。なので、そこは割り切って目的以上に複雑に作り込まないようにし、楽しみながら学ぶことが必要だと感じます。
なので、ローカル環境(自分のPC)で動かす前提で、可能な限り簡単な仕組みを考えました!
それが下記の図です!
非常に簡単な図なのですが、分からない人からすると「なんだか複雑な図」ですよね?
じゃあ、簡単って印象を皆に持ってもらうにはどうしたらいいか?
さらに考えました…そして…非常に簡単に感じる方法を思いつきました!
「3分で全て構築出来てしまえば、簡単って思ってくれるのでは!?」
つい先程、フロントエンドエンジニアとか、バックエンドエンジニアとか…特殊スキルを持った魔術師みたいな…ファンタジー世界の住人の紹介ページのようなものを見させられ、そこではフルスタックエンジニアというチートっぽい特殊人材でなくては構築不可能!…みたいなガクブルする話が展開されていました。
「そんなん自分には無理じゃね?」
…と、考えた人も多いでしょう。
そんな、Webシステムがですが…
「カップラーメンを待っている間にゼロからWebシステムの構築が出来てしまう!!」
バックエンドもフロントエンドも…両方です!
「あれ?またオレ何かやっちゃいました?」
状態ですよね!
これはインパクトあるだろうと思い、下記イベントに登壇することが決まっていたこともあり…
何も準備もない状態で思いつきだけで、下記の登壇タイトルを決めました!
3分AIプログラミング
「ゼロから構築!OpenAI APIを使ったWebアプリケーション」
あいかわらず、無茶なことをやりますねw
でも、私は自分を追い込んでいかないと全く動けないタイプなので、これが私のやり方なのかな…とも思います。無茶だとしても、未来のプログラマー(魔術師)のために、頑張らなくてはいけません。
…実際やってみると…
「3分ってメチャメチャ短けぇ…」
サッカーの試合で1点差で勝っている「終了前の3分間」なんかは・・・永遠のように長く感じるのですが、この動画では湯水のごとく時間が過ぎていきます。
操作手順は一切省かずに「すべて見せる」ことが前提であったため、ダウンロードやフォルダ作成なども削ることが出来ず、何をしているか分かる程度に早回ししても3分では終わる気がしません。
そんな時、勇気を貰える情報を得ました!
「本家の3分○⚪︎○○○は3分の放送ではなく、7分ぐらい放送している!!」
なんと!
あの有名な番組は3分でなかったのです!!
気を取り直して動画を作成・編集しました。トータル4分43秒!!
大体3分とも言えなくもない、悪くない時間に収まったかと思います。
出来上がった動画はこのような感じになります!!
一発で正解のコードを吐いてくれるo1さんは非常に優秀でした。
資料は下記となります!!
約3分の動画内で出来てしまった仕組みですが、この仕組みは非常に有用なものとなると考えています。
今後、この仕組みを利用し、様々なWebアプリのサンプルプログラムを提供できたらと思います!!
生成されたプログラムソース
最後に動画ではソースはチラ見せでしたので、ここで公開したいと思います。
ぜひ活用してください。
CDLEコミュニティメンバーの方はファイル形式でダウンロードできるようにもしました。
【バックエンドのプログラム】
.env
OPENAI_API_KEY=sk-XXXXXXXXX
OUTPUT_DIR=./output
OPENAI_CHAT_ENDPOINT=https://api.openai.com/v1/chat/completions
OPENAI_IMAGE_ENDPOINT=https://api.openai.com/v1/images/generations
api.py
import os
import time
import requests
import json
from datetime import datetime
from flask import Flask, request, jsonify
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "./output")
CHAT_ENDPOINT = os.environ.get("OPENAI_CHAT_ENDPOINT", "https://api.openai.com/v1/chat/completions")
IMAGE_ENDPOINT = os.environ.get("OPENAI_IMAGE_ENDPOINT", "https://api.openai.com/v1/images/generations")
# 出力ディレクトリがなければ作成
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
# リクエストごとのユニークID生成関数
# 「日時ミリ秒+連番」のイメージ例: 20231107_123456789_001
# ここでは単純なインクリメントカウンタを用いる
request_counter = 0
def generate_unique_id():
global request_counter
request_counter += 1
now = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")[:-3] #ミリ秒まで
return f"{now}_{request_counter:03d}"
@app.route('/api/chat', methods=['POST'])
def chat():
# クライアントから受け取るJSON例:
# {
# "model": "gpt-3.5-turbo",
# "messages": [{"role": "user", "content": "Hello"}],
# "temperature": 0.7,
# "max_tokens": 100,
# ... OpenAI chat completion APIに必要なパラメータ ...
# }
data = request.get_json(force=True)
unique_id = generate_unique_id()
# OpenAI APIコール
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json"
}
resp = requests.post(CHAT_ENDPOINT, headers=headers, json=data)
if resp.status_code == 200:
response_data = resp.json()
# 応答データを保存
# リクエストパラメータとレスポンスをJSONで保存
output_path = os.path.join(OUTPUT_DIR, f"chat_{unique_id}.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump({
"request": data,
"response": response_data
}, f, ensure_ascii=False, indent=2)
return jsonify(response_data), 200
else:
return jsonify({"error": "Failed to fetch from OpenAI API", "details": resp.text}), resp.status_code
@app.route('/api/image', methods=['POST'])
def image():
# クライアントから受け取るJSON例:
# {
# "prompt": "A beautiful sunset over the mountains",
# "n": 1,
# "size": "1024x1024",
# }
data = request.get_json(force=True)
unique_id = generate_unique_id()
headers = {
"Authorization": f"Bearer {OPENAI_API_KEY}",
"Content-Type": "application/json"
}
resp = requests.post(IMAGE_ENDPOINT, headers=headers, json=data)
if resp.status_code == 200:
response_data = resp.json()
# 画像URLを取得してダウンロード
if "data" in response_data and len(response_data["data"]) > 0:
for i, img_item in enumerate(response_data["data"]):
if "url" in img_item:
img_url = img_item["url"]
img_resp = requests.get(img_url)
if img_resp.status_code == 200:
# 画像保存
ext = ".png" # OpenAIはPNGを返すことが多いが、実際にはURL末尾かContent-Typeを見るべき
img_path = os.path.join(OUTPUT_DIR, f"image_{unique_id}_{i}{ext}")
with open(img_path, "wb") as f:
f.write(img_resp.content)
# JSONレスポンスも保存
output_path = os.path.join(OUTPUT_DIR, f"image_{unique_id}.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump({
"request": data,
"response": response_data
}, f, ensure_ascii=False, indent=2)
return jsonify(response_data), 200
else:
return jsonify({"error": "Failed to fetch from OpenAI API", "details": resp.text}), resp.status_code
if __name__ == "__main__":
# ローカル専用と想定
app.run(host="127.0.0.1", port=5000, debug=True)
requirements.txt
Flask==2.3.2
python-dotenv==1.0.0
requests==2.31.0
【フロントエンドのプログラム】
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenAI Chat & Image Generator</title>
<style>
body {
font-family: sans-serif;
background: #f9f9f9;
margin: 0; padding: 0;
}
header {
background: #004f9a;
color: #fff;
padding: 1rem;
text-align: center;
}
main {
max-width: 800px;
margin: 2rem auto;
background: #fff;
padding: 2rem;
border-radius: 8px;
}
h1, h2 {
margin-top: 0;
}
.section {
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
font-weight: bold;
margin-bottom: 0.5rem;
}
input[type="text"], textarea, select {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background: #004f9a;
color: #fff;
padding: 0.7rem 1.5rem;
font-size: 1rem;
border: none;
border-radius:4px;
cursor: pointer;
margin-right: 1rem;
}
button:hover {
background: #003f7a;
}
#chat-response, #image-response {
border: 1px solid #ddd;
padding:1rem;
border-radius:4px;
background:#f0f0f0;
margin-top:1rem;
white-space: pre-wrap;
}
.image-container img {
max-width: 100%;
margin-top:1rem;
}
</style>
</head>
<body>
<header>
<h1>OpenAI Chat & Image Generator</h1>
</header>
<main>
<div class="section" id="chat-section">
<h2>Chat</h2>
<div class="form-group">
<label for="chat-message">Your Message</label>
<textarea id="chat-message" rows="3" placeholder="Enter your message"></textarea>
</div>
<div class="form-group">
<label for="chat-model">Model</label>
<select id="chat-model">
<option value="gpt-4o">gpt-4o</option>
<option value="gpt-4">gpt-4</option>
</select>
</div>
<div class="form-group">
<label for="chat-temperature">Temperature</label>
<input type="text" id="chat-temperature" placeholder="0~1の範囲、例: 0.7" value="0.7">
</div>
<button id="send-chat">Send Chat</button>
<div id="chat-response"></div>
</div>
<hr>
<div class="section" id="image-section">
<h2>Image Generation</h2>
<div class="form-group">
<label for="image-prompt">Prompt</label>
<textarea id="image-prompt" rows="3" placeholder="Describe the image you want"></textarea>
</div>
<div class="form-group">
<label for="image-size">Size</label>
<select id="image-size">
<option value="1024x1024" selected>1024x1024</option>
</select>
</div>
<div class="form-group">
<label for="image-n">Number of images</label>
<input type="text" id="image-n" value="1">
</div>
<button id="generate-image">Generate Image</button>
<div id="image-response"></div>
<div class="image-container" id="image-container"></div>
</div>
</main>
<script>
document.getElementById('send-chat').addEventListener('click', async () => {
const message = document.getElementById('chat-message').value;
const model = document.getElementById('chat-model').value;
const temperature = parseFloat(document.getElementById('chat-temperature').value);
const payload = {
model: model,
messages: [{role: "user", content: message}],
temperature: isNaN(temperature) ? 0.7 : temperature,
};
const resDiv = document.getElementById('chat-response');
resDiv.textContent = 'Loading...';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (resp.ok) {
const json = await resp.json();
// Chat Completionsフォーマットを想定: json.choices[0].message.content
if (json.choices && json.choices.length > 0) {
resDiv.textContent = json.choices[0].message.content;
} else {
resDiv.textContent = JSON.stringify(json, null, 2);
}
} else {
const text = await resp.text();
resDiv.textContent = 'Error: ' + text;
}
} catch (e) {
resDiv.textContent = 'Error: ' + e.message;
}
});
document.getElementById('generate-image').addEventListener('click', async () => {
const prompt = document.getElementById('image-prompt').value;
const size = document.getElementById('image-size').value;
const n = parseInt(document.getElementById('image-n').value, 10);
const payload = {
model: "dall-e-3",quality: "standard", style: "vivid",
prompt: prompt,
n: isNaN(n) ? 1 : n,
size: size
};
const resDiv = document.getElementById('image-response');
const imgContainer = document.getElementById('image-container');
resDiv.textContent = 'Generating...';
imgContainer.innerHTML = '';
try {
const resp = await fetch('/api/image', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (resp.ok) {
const json = await resp.json();
resDiv.textContent = JSON.stringify(json, null, 2);
if (json.data && json.data.length > 0) {
json.data.forEach(imgData => {
if (imgData.url) {
const img = document.createElement('img');
img.src = imgData.url;
imgContainer.appendChild(img);
}
});
}
} else {
const text = await resp.text();
resDiv.textContent = 'Error: ' + text;
}
} catch (e) {
resDiv.textContent = 'Error: ' + e.message;
}
});
</script>
</body>
</html>
【フォルダ構成】
OpenAIとやりとりしたJSONデータや生成した画像を全て保存する仕組みまで、一発で構築出来てます。