OpenAI・Upstash・Next.jsを使ってAI搭載のストーリージェネレーターを構築する方法
このチュートリアルを進める前に、以下の環境がすでに整っていることを前提とします。まだの場合は、事前に準備しておいてください。
- RedisインスタンスとQStashインスタンスが作成済みのUpstashアカウント
- APIキーを取得できるOpenAIアカウント
- ストーリージェネレーター機能を実装するNext.jsプロジェクト
- プロジェクトをデプロイするためのVercelアカウント
はじめに
AIでオリジナルの物語を生成してみたいと思ったことはありませんか? OpenAIのCompletions APIとUpstashのQStashおよびRedisを組み合わせれば、自然言語処理を活用した独自のストーリージェネレーターを、これまで以上に簡単に作成できます。本チュートリアルでは、これらのツールをセットアップし、ユニークで魅力的な物語を生成するまでの一連の手順を解説します。

アプリのその他の画面は以下の通りです。
- ストーリー作成フォーム
- 生成中の状態表示
- 生成されたストーリーの表示
アーキテクチャ
コードを読み進めればアプリの仕組みはおおよそ把握できると思いますが、まず全体像をつかむために、下の画像でアプリケーションフローの一部と、各コンポーネントがどのように連携しているかを確認しておきましょう。

プロジェクトのセットアップ
最初に、Next.jsプロジェクトを作成します。TypeScript対応の新規Next.jsプロジェクトは、次のコマンドで生成できます。詳細なセットアップ手順は公式ドキュメントを参照してください。
なお、本チュートリアルではフロントエンドのフォームの見た目を整えるためにTailwind CSS(formsプラグインとtypographyプラグイン込み)も導入していますが、これは完全に任意です。デザインにこだわらない場合はスキップしても問題ありません。
続いて、UpstashのQStashとRedisのライブラリを以下のコマンドでインストールします。
npm install @upstash/qstash
npm install @upstash/redis
次に、.env.localファイルを作成し、以下のキー(値は各サービスのコンソールから取得したもの)を設定します。
SITE_URL=https://your-project-url.vercel.app
OPENAI_API_KEY=
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
QStashとRedisのトークンはUpstashコンソールで確認できます。OpenAIのAPIキーはOpenAIのダッシュボードから取得してください。SITE_URLには、Vercelでプロジェクトを作成し基本的なNext.jsアプリをデプロイした後に発行されるURLを指定します。
フロントエンドの実装
ここからは、ストーリーのプロンプトを入力するためのページとフォームを作成していきます。必要なのは、テーマ入力用のテキストフィールドと送信ボタンです。
ストーリー作成フォーム
ファイル:pages/index.tsx
import { RefObject, useRef, useState } from "react";
import Head from "next/head";
import useInterval from "../hooks/useInterval";
export default function Home() {
const [generating, setGenerating] = useState<boolean>(false);
const [messageId, setMessageId] = useState<string | null>(null);
const [story, setStory] = useState<string[]>([]);
const themeRef: RefObject<HTMLInputElement> = useRef(null);
const characterRef: RefObject<HTMLInputElement> = useRef(null);
const moralRef: RefObject<HTMLInputElement> = useRef(null);
useInterval(
async () => {
await fetch(`/api/poll?id=${messageId}`)
.then((res: any) => res.json())
.then((data: any) => {
if (!data.choices) {
return;
}
setGenerating(false);
setMessageId(null);
setStory(data.choices[0].text.split("\n\n"));
})
.catch((err: any) => console.error(err));
},
messageId ? 1000 : null,
);
async function generateStory(event: any) {
event.preventDefault();
setGenerating(true);
await fetch("/api/create", {
method: "POST",
body: JSON.stringify({
theme: themeRef.current?.value,
character: characterRef.current?.value,
moral: moralRef.current?.value,
}),
headers: { "Content-Type": "application/json" },
})
.then((res: any) => res.json())
.then((data: any) => setMessageId(data.id))
.catch((err: any) => console.error(err));
}
return (
<>
<Head>
<title>StoryTime</title>
<meta
name="description"
content="A simple Next.js application which allows you to create stories using AI."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="my-16 flex flex-col items-center justify-center md:my-32">
<h1 className="text-5xl font-black">StoryTime</h1>
{story.length > 0 && (
<div className="mx-auto mt-10 max-w-3xl">
<div className="prose lg:prose-xl w-full">
{story.map((paragraph: string, index: number) => (
<p key={index}>{paragraph}</p>
))}
</div>
<div className="text-center">
<button
type="button"
onClick={() => setStory([])}
className="mt-6 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2"
>
Start Over
</button>
</div>
</div>
)}
{story.length == 0 && (
<form
onSubmit={generateStory}
className="mt-10 flex w-full max-w-lg flex-col items-center"
>
<div className="w-full space-y-4">
<div>
<label htmlFor="theme" className="text-sm font-semibold">
My story is about
</label>
<input
name="theme"
id="theme"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="two friends going on an adventure"
ref={themeRef}
required
/>
</div>
<div>
<label htmlFor="character" className="text-sm font-semibold">
My main character is
</label>
<input
name="character"
id="character"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="a dog named Spot"
ref={characterRef}
required
/>
</div>
<div>
<label htmlFor="moral" className="text-sm font-semibold">
The moral of my story is
</label>
<input
name="moral"
id="moral"
type="text"
className="mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500"
placeholder="to always be kind"
ref={moralRef}
required
/>
</div>
</div>
<button
type="submit"
disabled={generating}
className="mt-6 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
{generating ? "Generating..." : "Generate"}
</button>
</form>
)}
</div>
</main>
</>
);
}
このファイルでは、テーマ・主人公・教訓を入力できるフォームを持つReactコンポーネントを定義しています。フォームが送信されると、入力された3つの値をボディに含めて/api/createエンドポイントへPOSTリクエストを送信します。
その後、コンポーネントはポーリング状態に入り、1秒ごとに前回のストーリー作成リクエストで受け取ったメッセージIDを添えて/api/pollエンドポイントへGETリクエストを送ります。このメッセージIDによって、「どのリクエストがどのストーリーに対応するか」を追跡でき、OpenAI側での生成が完了したタイミングを検知できるようになっています。
/api/pollからのレスポンスにchoicesプロパティが含まれていれば、ストーリーの生成が正常に完了したことを意味します。そこでポーリングを停止し、受け取ったテキストを段落ごとに分割して表示します。
インターバル用カスタムフック
ファイル:hooks/useInterval.ts
import { useEffect, useRef } from "react";
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;
useIntervalフックは、useEffectとuseRefを組み合わせてインターバル処理とコールバック関数を管理します。Reactのコンポーネントライフサイクルとシームレスに連携するため、パフォーマンスへの影響を抑えつつ、保守性の高いコードを実現できます。このフックの詳細については、関連ドキュメントや解説記事を参照してください。
APIの実装
続いて、callback・poll・createの各APIファイルと、RedisおよびQStashライブラリの利用部分を作成します。
ストーリー作成API
ファイル:pages/api/create.ts
import type { NextApiRequest, NextApiResponse } from "next";
import qstashClient from "../../lib/qstash";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { theme, character, moral }: any = req.body;
qstashClient
.publishJSON({
url: "https://api.openai.com/v1/completions",
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
"Content-Type": "application/json",
"Upstash-Callback": `${process.env.SITE_URL}/api/callback`,
"Upstash-Forward-Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: {
model: "text-davinci-003",
prompt: `Write a children's story about ${theme}, which has a main character who is ${character} with the moral of the story being ${moral}.`,
max_tokens: 500,
temperature: 0.75,
},
})
.then((data: any) => {
return res.status(202).json({ id: data.messageId });
})
.catch((error: any) => {
return res.status(500).json({ message: error.message });
});
}
まず、リクエストメソッドがPOSTであることを確認し、そうでなければステータスコード400(クライアントエラー)のレスポンスを返します。問題なければ、リクエストボディからtheme・character・moralの3つのフィールドを取り出します。
次に、qstashClientオブジェクトのpublishJSONメソッドを呼び出します。これにより、テーマ・主人公・教訓をもとに子ども向けの物語を生成するよう指示するプロンプトを含んだJSONボディで、OpenAI APIへPOSTリクエストが送信されます。ヘッダーには、QSTASH_TOKEN環境変数に格納されたトークンを使った認証ヘッダーや、OPENAI_API_KEYをOpenAIへのリクエスト時に転送するためのヘッダーなどが設定されています。
publishJSONの呼び出しが成功すると、後のポーリングで使用するためのメッセージIDを返します。エラーが発生した場合は、ステータスコード500(サーバー内部エラー)とエラーメッセージを含むレスポンスを返します。
コールバックAPI
ファイル:pages/api/callback.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { body }: any = req;
try {
const decoded = Buffer.from(body.body, "base64").toString("utf-8");
await redis.set(body.sourceMessageId, decoded);
return res.status(200).send(decoded);
} catch (error) {
return res.status(500).json({ error });
}
}
このハンドラーでは、まずBase64エンコードされて届くリクエストボディのデコードを試みます。デコードに成功したら、QStashに最初のリクエストを送った際に返されたメッセージIDと同じキーで、デコード済みの文字列をRedisに保存します。
最後に、ステータスコード200(成功)とデコード済みの文字列をレスポンスとして返します。エラーが発生した場合は、ステータスコード500とエラーメッセージを返します。
ポーリングAPI
ファイル:pages/api/poll.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { id }: any = req.query;
try {
const data = await redis.get(id);
if (!data) {
return res
.status(404)
.json({ message: "Data for supplied ID not found" });
}
return res.status(200).json(data);
} catch (error: any) {
return res.status(500).json({ message: error.message });
}
}
まず、リクエストのクエリオブジェクトからidを取り出します。次に、そのidをキーとしてRedisからデータを取得しようとし、データが見つからない場合は、ステータスコード404(リソースが見つからない)とその旨のメッセージを返します。
該当するキーのデータが見つかれば、ステータスコード200(成功)とともにデータを返します。エラーが発生した場合は、ステータスコード500とエラーメッセージを返します。
ライブラリクライアント
最後に、QStashとRedisのクライアントを生成するための2つのファイルを作成します。いずれも外部サービスとやり取りするためのオブジェクトをエクスポートしており、ストーリー生成処理の中で使用されます。
ファイル:lib/qstash.ts
import { Client } from "@upstash/qstash";
const qstashClient = new Client({
token: process.env.QSTASH_TOKEN as string,
});
export default qstashClient;
QStashクライアントは、QSTASH_TOKEN環境変数に格納されたトークンで初期化されます。このオブジェクトを使って、Upstash QStashサービスへのHTTPリクエストを送信できます。
ファイル:lib/redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL as string,
token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
});
export default redis;
Redisクライアントは、UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENという環境変数に格納されたURLとトークンで初期化されます。このオブジェクトを使えば、Upstash Redis REST API経由でRedisデータベースへのデータ保存・取得が可能です。
まとめ
OpenAIのCompletions APIとUpstashのQStash・Redisを組み合わせれば、自然言語処理によるカスタムストーリー生成は簡単に実現できます。このチュートリアルに沿って進めば、自分専用のストーリー生成システムを構築でき、さらに独自の改良を加えることもできるはずです。
ソースコード全体は公開リポジトリで確認できます。
さらなる改善アイデア
このストーリージェネレーターを出発点として、次のような拡張が考えられます。
- フロントエンドのデザインをより華やかで視覚的に魅力的なものに刷新する
- OpenAIのDALL·Eを連携させ、プロンプトに基づいた挿絵画像をストーリーに追加する
- 書籍印刷サービスのAPIと接続し、ユーザーが紙の本として注文できるようにする
可能性は無限に広がっています。ぜひ楽しみながら開発を進めてください。今回の成果物は、OpenAI・QStash・Redisを活用する他のプロジェクトの土台としても活用できます。
-
Astro・Upstash・GitHubで作る無料オープンソースのLinkTree代替サービス構築ガイド
この記事では、LinkTreeのオープンソース代替サービスであるitsmy.fyiが、Upstash・Astro・GitHub・Edgioをどのように組み合わせて構築されているのかを詳しく解説します。Upstashは全ユーザーのデータ管理(CRUD操作)を担い、GitHub APIと比べてはるかに寛容なレート制限を提供してくれるため、きめ細かなレートリミットの実装も容易になりました。 使用する技術スタック Astro — フロントエンドおよびバックエンド Upstash — レート制限とCRUD操作 GitHub Issue & Webhooks — ユーザープロフィールを管理する
-
OpenAI・Upstash・Next.jsを使ってAI搭載のストーリージェネレーターを構築する方法
このチュートリアルを進める前に、以下の環境がすでに整っていることを前提とします。まだの場合は、事前に準備しておいてください。 RedisインスタンスとQStashインスタンスが作成済みのUpstashアカウント APIキーを取得できるOpenAIアカウント ストーリージェネレーター機能を実装するNext.jsプロジェクト プロジェクトをデプロイするためのVercelアカウント はじめに AIでオリジナルの物語を生成してみたいと思ったことはありませんか? OpenAIのCompletions APIとUpstashのQStashおよびRedisを組み合わせれば、自然言語処理を活用した独自のス