見出し画像

OpenAI API を使って、ChatGPT と対話するアプリを Next.js で実装する

はじめに

お仕事で ChatGPT をアプリに組み込みたいと言われて、Next.js で実装してみたのですが、いくつかハマったことがあったので記事にしてみます。

今回作ったもの

Next.js + AWS Lambda (SAM)OpenAI の API を使い以下のように ChatGPT と対話できるアプリを作りました。
(お仕事で作ったものとは違います。)

アプリ デモ画面

Tell me about Shohei Otani. と話しかけて ChatGPT から返ってきた回答をストリーム(会話のように少しづつ表示する)形式で画面に表示しています。

実装上のポイント

今回もソースコードを公開していますので、とにかくコードをみたい、動かしたいという人は下記の GitHub リポジトリを参照ください。

ざっくりどんなアプリか

  • 画面から入力されたメッセージを POST api/chat して

  • POST されたメッセージを OpenAI API に投げる

  • その際、stream: true を指定し、ストリームでレスポンスを受け取る

  • 受け取ったレスポンスを setInterval で少しづつ表示する

  • UI ライブラリには Ant Design を使用

Next.js v14 (App Router) に対応
はじめ下記の記事(Next.js の開発元 Vercel のブログ)と記事内で紹介されている GitHub リポジトリを参考に実装していましたが、実行時にエラーになってしまって期待通り動作しませんでした。

で、この事象にぶち当たっている人は他にもいるはずと思って、プルリクを探していたら、それっぽいのが見つかりました。

上記プルリクで修正しているポイントを列挙すると以下のようになります。

  • openai じゃなくて openai-edge を使う

    • openai-edge は、Edge 環境でも動くように、Axios 使わずに fetch API で書き直したものらしい

  • ai パッケージの OpenAIStream で createChatCompletion のレスポンスを変換する

  • export const config = { runtime: "edge" } じゃなくて export const runtime = 'edge'

    • そもそも 参考にした記事が Vercel Edge Functions で動かす前提だったので、AWS Lambda では Edge Runtime 不要だったのかも?(未確認)

最終的に、src/app/api/chat/route.ts のソースは以下のようになりました。
かなりスッキリしました。

import { type NextRequest } from 'next/server'
import { Configuration, CreateChatCompletionRequest, OpenAIApi } from 'openai-edge'
import { OpenAIStream, StreamingTextResponse } from 'ai'
import { OpenAiChatPayload } from '@/types'

export const runtime = 'edge'

export async function POST(req: NextRequest): Promise<Response> {
  const { model, messages } = (await req.json()) as OpenAiChatPayload

  const payload: CreateChatCompletionRequest = {
    model,
    messages,
    stream: true,
  }

  const config = new Configuration({
    apiKey: process.env.OPENAI_API_KEY,
  })
  const openai = new OpenAIApi(config)

  const response = await openai.createChatCompletion(payload)
  const stream = OpenAIStream(response)

  return new StreamingTextResponse(stream)
}

ページ側の実装 src/app/page.tsx は下記のとおり

'use client'

import { Button, Form, Input, Spin } from 'antd'
import { useState } from 'react'
import { OpenAiChatPayload } from '@/types'
const { TextArea } = Input

type FormData = {
  model: string
  chat: string
}

export default function Home() {
  const [formData, setFormData] = useState<FormData>({ model: 'gpt-4-turbo-preview', chat: '' })
  const [feedback, setFeedback] = useState<string>('')
  const [loading, setLoading] = useState<boolean>(false)
  
  const onValuesChange = (_changedFields: any, allFields: FormData) => {
    setFormData(allFields)
  }
  
  const onSubmit = async (formData: FormData) => {
    const payload: OpenAiChatPayload = {
      model: formData.model,
      messages: [
        { role: 'user', content: formData.chat },
      ],
    }
    
    setFeedback('')
    setLoading(true)
    
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify(payload),
      headers: {
        'Content-Type': 'application/json',
      },
    })
    
    setLoading(false)
    
    if (!response.ok) {
      throw new Error(response.statusText)
    }
    
    const data = response.body
    if (!data) {
      return
    }
    
    const reader = data.getReader()
    const decoder = new TextDecoder()
    let done = false
    let buffer = ''
    
    const timer = setInterval(async () => {
      const { value, done: doneReading } = await reader.read()
      done = doneReading
      const chunkValue = decoder.decode(value)
      buffer += chunkValue
      setFeedback(buffer)
      if (done) {
        clearTimeout(timer)
      }
    }, 50)
  }
  
  return (
    <>
      <Form initialValues={formData} onFinish={onSubmit} onValuesChange={onValuesChange} colon={false} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
        <Form.Item wrapperCol={{ offset: 4, span: 20 }}>
          <h1 style={{ marginBlockEnd: '0' }}>Next.js OpenAI Stream</h1>
        </Form.Item>
        <Form.Item name={'model'} label={'Model'}>
          <Input disabled />
        </Form.Item>
        <Form.Item name={'chat'} label={'Chat'}>
          <TextArea rows={10} />
        </Form.Item>
        <Form.Item wrapperCol={{ offset: 4, span: 20 }}>
          <Button htmlType={'submit'} type={'primary'}>
            Submit
          </Button>
        </Form.Item>
        <Form.Item wrapperCol={{ offset: 4, span: 20 }}>
          <div>{feedback}</div>
        </Form.Item>
      </Form>
      <Spin fullscreen spinning={loading} />
    </>
  );
}

おわりに

ざっくり感想は、以下のとおり。

  • チームのAIエンジニアからは Streamlit をお勧めされたのだが、それはまたの機会にチャレンジしたい

  • 1年前とはいえ、開発元のブログが古くなってしまっているのには驚いた

  • ai というパッケージあるんですね(Vercel が出していて、Hugging Face の API などにも対応)

  • API エンドポイントを用意できたので、モックアプリとしてだけでなく別のアプリから ChatGPT と対話できそうでよかった

と、まあ、ざっとこんな感じでしょうか。
ではでは。

追記

この記事より詳しい解説を見つけたのでリンク貼っておきます。