Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

สร้าง AI-Powered Story Generator ด้วย OpenAI, Upstash และ Next.js

สำหรับโพสต์นี้ เราจะทำ assumptions เล็กน้อย แต่ควรมี:

  • บัญชี Upstash ที่มี Redis และ QStash instance สร้างแล้ว
  • บัญชี OpenAI พร้อม API key
  • Next.js project สำหรับสร้าง story generator functionality
  • บัญชี Vercel สำหรับ deploy โปรเจกต์

บทนำ

เคยอยาก generate เรื่องราวของตัวเองด้วย AI ไหม? ด้วย OpenAI completions API และ Upstash QStash และ Redis ตอนนี้สร้าง custom stories ด้วย natural language processing ง่ายกว่าคิด ใน tutorial นี้ เราจะ walk through การ setup และใช้ tools เหล่านี้ generate unique และ engaging stories

สร้าง AI-Powered Story Generator ด้วย OpenAI, Upstash และ Next.js

ดูรูปเพิ่มเติมของแอป:

  • Create Story Form
  • Generating Story State
  • Generated Story Displayed

Architecture

คุณน่าจะเข้าใจ app setup จากการอ่านโค้ด แต่เป็น higher level overview มีรูปด้านล่างแสดงบางส่วนของ application flow และการ communicate

สร้าง AI-Powered Story Generator ด้วย OpenAI, Upstash และ Next.js

Project Setup

ก่อนอื่น สร้าง Next.js project รันคำสั่งสร้าง Next.js project ใหม่พร้อม TypeScript หาขั้นตอน setup Next.js ได้ ที่นี่

เพื่อ tutorial นี้ เราติดตั้ง Tailwind CSS (forms และ typography ด้วย) แต่ optional เฉพาะ frontend form styling

ต่อไป ติดตั้ง Upstash QStash และ Redis libraries:

npm install @upstash/qstash
npm install @upstash/redis

สร้าง .env.local populate ด้วย keys (และค่าจากที่เกี่ยวข้อง):

SITE_URL=https://your-project-url.vercel.app
OPENAI_API_KEY=
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

หา QStash และ Redis tokens ใน Upstash console, OpenAI API key ที่นี่, site URL ใน Vercel dashboard หลังสร้าง project และ deploy basic Next.js project

Frontend Setup

สร้าง page และ form สำหรับ input story prompt ต้องการ text field สำหรับ prompt และ submit button

Story Creation

File: 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>
    </>
  );
}

ไฟล์นี้ define React component แสดง form ให้ user input theme, character, และ moral สำหรับ story เมื่อ submit form ส่ง POST request ไป /api/create endpoint พร้อม theme, character, moral values เป็น body

Component เข้าสู่ polling state ส่ง GET request ไป /api/poll ทุกวินาที พร้อม message identifier จาก story creation request ก่อนหน้า เพื่อ track story creation request ไป story ที่ polling เพื่อเช็คว่า generated โดย OpenAI เสร็จหรือยัง

เมื่อ response จาก /api/poll มี choices property เรารู้ว่า polling request ส่งคืน story ที่สร้างสำเร็จ component หยุด polling แสดง story text โดย split เป็น paragraphs render แต่ละ paragraph แยกกัน

Interval Hook

File: 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 hook ใช้ useEffect และ useRef hooks จัดการ interval และ callback function ทำงาน seamless กับ React component lifecycle และให้ convenient way จัดการ interval และ callback ภายใน React component optimize performance และทำ codebase maintainable มากขึ้น อ่านเพิ่มเติม ที่นี่ และ ที่นี่

API Setup

ก่อนอื่น สร้าง callback, poll, create files, และ Redis/QStash library usage

Story Creation

File: 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 });
    });
}

เช็ค request method เป็น POST ส่ง response status 400 (client error) ถ้าไม่ใช่ destructure theme, character, moral จาก request body

เรียก publishJSON method บน qstashClient ส่ง POST request ไป OpenAI API พร้อม JSON body มี prompt generate children's story จาก theme, character, moral ตั้ง headers หลายอย่าง รวมถึง authorization header ด้วย token ใน QSTASH_TOKEN env variable และ forwarded authorization header ส่ง OPENAI_API_KEY ใช้พร้อม OpenAI API request

ส่งคืน message ID ของ request ถ้า publishJSON สำเร็จ ใช้ polling เช็คว่าร้องขอเสร็จแล้ว ถ้า error ส่ง response status 500 (internal server error) พร้อม error message

Callback

File: 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 });
  }
}

พยายาม decode body ของ incoming request ซึ่งเป็น base64-encoded string ถ้าสำเร็จ เก็บ decoded string ใน Redis ภายใต้ key เดียวกับที่ได้ตอนส่ง initial request ไป QStash

ส่ง response status 200 (success) พร้อม decoded string ถ้า error ส่ง status 500 พร้อม error message

Polling

File: 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 });
  }
}

Destructure id จาก query object พยายามดึง data ใน Redis ภายใต้ id ถ้าไม่เจอ ส่ง status 404 (not found) พร้อม message ถ้ามี data ส่ง status 200 พร้อม data ถ้า error ส่ง status 500 พร้อม error message

Libs

สร้าง 2 ไฟล์สำหรับ QStash และ Redis clients ใช้ใน story generation process ทั้งคู่ export object ใช้ interact กับ external services

File: lib/qstash.ts

import { Client } from "@upstash/qstash";

const qstashClient = new Client({
  token: process.env.QSTASH_TOKEN as string,
});

export default qstashClient;

QStash client initialize ด้วย token ใน QSTASH_TOKEN environment variable object นี้ใช้ส่ง HTTP requests ไป Upstash QStash service

File: 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 client initialize ด้วย URL และ token ใน UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN environment variables object นี้ใช้เก็บและดึงข้อมูลใน Redis database ผ่าน Upstash Redis REST API

สรุป

ด้วย OpenAI completions API, Upstash QStash และ Redis ง่ายต่อการ generate custom stories ด้วย natural language processing ด้วย tutorial นี้ คุณควร setup ระบบ generate stories ด้วย tools นี้ได้ และทำ changes/improvements ของตัวเอง

ดู source code เต็มได้ ที่นี่

Further Improvements

ไอเดียนิดหน่อยสำหรับต่อยอด จาก story generator นี้:

  • อัปเดต frontend styling ให้ visually appealing และ colorful มากขึ้น
  • เพิ่ม Dall-E image generation ด้วย OpenAI ให้ stories จาก prompt ที่กำหนด
  • Hook output ไป book printing service via API ให้ user สั่งหนังสือ physical ได้

Possibilities และ directions เยอะมาก enjoy the process คุณแม้แต่ใช้งาน so far เป็น base สำหรับโปรเจกต์อื่นที่ใช้ OpenAI, QStash และ Redis