使用GPT-3.5和Next.js构建聊天机器人:详细指南

1,524 阅读4分钟

image.png 随着人工智能和ChatGPT的炒作,我认为应该发布一个关于如何建立我们自己的ChatGPT驱动的聊天机器人的教程!这些代码的大部分已经在Vercel的网站上作为模板开源了,所以你可以随时克隆该repo来开始使用,或者如果你只是想与ChatGPT互动而不需要注册,你可以在我的网站上查看它

让我们开始吧!这些是我们将要使用的技术:

  • Next.js
  • TypeScript
  • Tailwind(尽管我不会在这里介绍这个)。
  • OpenAI API

开始使用

让我们来设置我们的项目。我喜欢使用pnpm和create-t3-app,但也可以随意使用你选择的软件包管理器和CLI来开始。

项目设置

使用 pnpm 和 create-t3-app:

pnpm create t3-app@latest

进入全屏模式 退出全屏模式

  1. 命名你的项目

  2. 选择TypeScript

  3. 选择Tailwind

  4. 为Git仓库选择Y

  5. 选择Y来运行pnpm安装

  6. 按回车键,输入默认的导入别名

create-t3-app

现在我们有了一个启动的Next.js项目,让我们确保我们有一个OpenAI API密钥可以使用。要获取你的OpenAI API密钥,你需要在openai.com上创建一个用户账户,然后访问OpenAI仪表板上的API密钥部分,创建一个新的API密钥。

创建你的环境变量

在你的项目根目录下,创建一个.env.local文件。它应该看起来像这样:

# Your API key
OPENAI_API_KEY=PASTE_API_KEY_HERE

# The temperature controls how much randomness is in the output
AI_TEMP=0.7

# The size of the response
AI_MAX_TOKENS=100
OPENAI_API_ORG=

进入全屏模式 退出全屏模式

让我们也设置一些模板css,以便我们的布局是响应式的。让我们安装 Vercel 示例 ui-layout。

pnpm i @vercel/examples-ui

进入全屏模式 退出全屏模式

你的tailwind.config.js文件应该看起来像这样:

module.exports = {
  presets: [require('@vercel/examples-ui/tailwind')],
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
    './node_modules/@vercel/examples-ui/**/*.js',
  ],
}

进入全屏模式 退出全屏模式

你的postcss.config.js应该是这样的:

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

进入全屏模式 退出全屏模式

最后,你的 _app.tsx 应该看起来像这样:

import type { AppProps } from 'next/app'
import { Analytics } from '@vercel/analytics/react'
import type { LayoutProps } from '@vercel/examples-ui/layout'

import { getLayout } from '@vercel/examples-ui'

import '@vercel/examples-ui/globals.css'

function App({ Component, pageProps }: AppProps) {
  const Layout = getLayout<LayoutProps>(Component)

  return (
    <Layout
      title="ai-chatgpt"
      path="solutions/ai-chatgpt"
      description="ai-chatgpt"
    >
      <Component {...pageProps} />
      <Analytics />
    </Layout>
  )
}

export default App

进入全屏模式 退出全屏模式

现在我们有了所有的模板,我们要做的是什么?让我们创建一个检查表:

  1. 我们需要能够监听来自OpenAI API的响应。

  2. 我们需要能够发送用户输入到OpenAI API。

  3. 我们需要在某种聊天用户界面中显示所有这些内容。

创建一个数据流

为了接收来自OpenAI API的数据,我们可以创建一个OpenAIStream函数

在你的项目根目录下,创建一个名为utils的文件夹,然后在里面创建一个名为OpenAiStream.ts的文件。将这段代码复制并粘贴到其中,并确保为任何导入做必要的npm/pnpm安装。

pnpm install eventsource-parser

进入全屏模式 退出全屏模式

import {
  createParser,
  ParsedEvent,
  ReconnectInterval,
} from 'eventsource-parser'

export type ChatGPTAgent = 'user' | 'system' | 'assistant'

export interface ChatGPTMessage {
  role: ChatGPTAgent
  content: string
}

export interface OpenAIStreamPayload {
  model: string
  messages: ChatGPTMessage[]
  temperature: number
  top_p: number
  frequency_penalty: number
  presence_penalty: number
  max_tokens: number
  stream: boolean
  stop?: string[]
  user?: string
  n: number
}

export async function OpenAIStream(payload: OpenAIStreamPayload) {
  const encoder = new TextEncoder()
  const decoder = new TextDecoder()

  let counter = 0

  const requestHeaders: Record<string, string> = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ''}`,
  }

  if (process.env.OPENAI_API_ORG) {
    requestHeaders['OpenAI-Organization'] = process.env.OPENAI_API_ORG
  }

  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    headers: requestHeaders,
    method: 'POST',
    body: JSON.stringify(payload),
  })

  const stream = new ReadableStream({
    async start(controller) {
      // callback
      function onParse(event: ParsedEvent | ReconnectInterval) {
        if (event.type === 'event') {
          const data = event.data
          // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
          if (data === '[DONE]') {
            console.log('DONE')
            controller.close()
            return
          }
          try {
            const json = JSON.parse(data)
            const text = json.choices[0].delta?.content || ''
            if (counter < 2 && (text.match(/\n/) || []).length) {
              // this is a prefix character (i.e., "\n\n"), do nothing
              return
            }
            const queue = encoder.encode(text)
            controller.enqueue(queue)
            counter++
          } catch (e) {
            // maybe parse error
            controller.error(e)
          }
        }
      }

      // stream response (SSE) from OpenAI may be fragmented into multiple chunks
      // this ensures we properly read chunks and invoke an event for each SSE event stream
      const parser = createParser(onParse)
      for await (const chunk of res.body as any) {
        parser.feed(decoder.decode(chunk))
      }
    },
  })

  return stream
}

进入全屏模式 退出全屏模式

OpenAIStream是一个允许你从OpenAI API流式传输数据的函数。它接收一个有效载荷对象作为参数,其中包含请求的参数。然后它向OpenAI API发出请求并返回一个ReadableStream对象。该流包含从响应中解析出来的事件,每个事件都包含可用于生成响应的数据。该函数还记录了被解析的事件的数量,这样它就可以在达到终点时关闭该流。

现在我们可以从API接收数据了,让我们创建一个组件,它可以接收用户信息,并将其发送给api以获得响应。

创建聊天机器人组件

如果我们想的话,我们可以在一个组件中创建我们的聊天机器人,但为了使文件更有条理,我们把它设置成三个组件。

在你的根目录下,创建一个名为组件的文件夹。在其中,创建三个文件:

  1. Button.tsx

  2. Chat.tsx

  3. ChatLine.tsx

按钮组件

import clsx from 'clsx'

export function Button({ className, ...props }: any) {
  return (
    <button
      className={clsx(
        'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
        'bg-zinc-600 font-semibold text-zinc-100 hover:bg-zinc-400 active:bg-zinc-800 active:text-zinc-100/70',
        className
      )}
      {...props}
    />
  )
}

进入全屏模式 退出全屏模式

非常简单的按钮,使Chat.tsx文件更小一点。

ChatLine组件

pnpm install clsx
pnpm install react-wrap-balancer

进入全屏模式 退出全屏模式

import clsx from 'clsx'
import Balancer from 'react-wrap-balancer'

// wrap Balancer to remove type errors :( - @TODO - fix this ugly hack
const BalancerWrapper = (props: any) => <Balancer {...props} />

type ChatGPTAgent = 'user' | 'system' | 'assistant'

export interface ChatGPTMessage {
  role: ChatGPTAgent
  content: string
}

// loading placeholder animation for the chat line
export const LoadingChatLine = () => (
  <div className="flex min-w-full animate-pulse px-4 py-5 sm:px-6">
    <div className="flex flex-grow space-x-3">
      <div className="min-w-0 flex-1">
        <p className="font-large text-xxl text-gray-900">
          <a href="#" className="hover:underline">
            AI
          </a>
        </p>
        <div className="space-y-4 pt-4">
          <div className="grid grid-cols-3 gap-4">
            <div className="col-span-2 h-2 rounded bg-zinc-500"></div>
            <div className="col-span-1 h-2 rounded bg-zinc-500"></div>
          </div>
          <div className="h-2 rounded bg-zinc-500"></div>
        </div>
      </div>
    </div>
  </div>
)

// util helper to convert new lines to <br /> tags
const convertNewLines = (text: string) =>
  text.split('\n').map((line, i) => (
    <span key={i}>
      {line}
      <br />
    </span>
  ))

export function ChatLine({ role = 'assistant', content }: ChatGPTMessage) {
  if (!content) {
    return null
  }
  const formatteMessage = convertNewLines(content)

  return (
    <div
      className={
        role != 'assistant' ? 'float-right clear-both' : 'float-left clear-both'
      }
    >
      <BalancerWrapper>
        <div className="float-right mb-5 rounded-lg bg-white px-4 py-5 shadow-lg ring-1 ring-zinc-100 sm:px-6">
          <div className="flex space-x-3">
            <div className="flex-1 gap-4">
              <p className="font-large text-xxl text-gray-900">
                <a href="#" className="hover:underline">
                  {role == 'assistant' ? 'AI' : 'You'}
                </a>
              </p>
              <p
                className={clsx(
                  'text ',
                  role == 'assistant' ? 'font-semibold font- ' : 'text-gray-400'
                )}
              >
                {formatteMessage}
              </p>
            </div>
          </div>
        </div>
      </BalancerWrapper>
    </div>
  )
}

进入全屏模式 退出全屏模式

这段代码是一个React组件,显示一个聊天线。它接收了两个道具,角色和内容。角色道具用于确定哪个代理在发送消息,是用户、系统还是助手。内容道具用于显示消息。

该组件首先检查内容道具是否为空,如果是,则返回空。如果内容道具不是空的,它会将内容中的任何新行转换为中断标签。然后,它渲染一个内部有BalancerWrapper组件的div。BalancerWrapper组件用于将聊天线包裹在一个响应式布局中。在BalancerWrapper组件内,该组件渲染了一个里面有flex容器的div。弹性容器用于显示消息发送者和消息内容。消息发送者是由角色道具决定的,而消息内容是由内容道具决定的。然后该组件返回带有BalancerWrapper组件的div。

聊天组件

pnpm install react-cookie

进入全屏模式 退出全屏模式

import { useEffect, useState } from 'react'
import { Button } from './Button'
import { type ChatGPTMessage, ChatLine, LoadingChatLine } from './ChatLine'
import { useCookies } from 'react-cookie'

const COOKIE_NAME = 'nextjs-example-ai-chat-gpt3'

// default first message to display in UI (not necessary to define the prompt)
export const initialMessages: ChatGPTMessage[] = [
  {
    role: 'assistant',
    content: 'Hi! I am a friendly AI assistant. Ask me anything!',
  },
]

const InputMessage = ({ input, setInput, sendMessage }: any) => (
  <div className="mt-6 flex clear-both">
    <input
      type="text"
      aria-label="chat input"
      required
      className="min-w-0 flex-auto appearance-none rounded-md border border-zinc-900/10 bg-white px-3 py-[calc(theme(spacing.2)-1px)] shadow-md shadow-zinc-800/5 placeholder:text-zinc-400 focus:border-teal-500 focus:outline-none focus:ring-4 focus:ring-teal-500/10 sm:text-sm"
      value={input}
      onKeyDown={(e) => {
        if (e.key === 'Enter') {
          sendMessage(input)
          setInput('')
        }
      }}
      onChange={(e) => {
        setInput(e.target.value)
      }}
    />
    <Button
      type="submit"
      className="ml-4 flex-none"
      onClick={() => {
        sendMessage(input)
        setInput('')
      }}
    >
      Say
    </Button>
  </div>
)

export function Chat() {
  const [messages, setMessages] = useState<ChatGPTMessage[]>(initialMessages)
  const [input, setInput] = useState('')
  const [loading, setLoading] = useState(false)
  const [cookie, setCookie] = useCookies([COOKIE_NAME])

  useEffect(() => {
    if (!cookie[COOKIE_NAME]) {
      // generate a semi random short id
      const randomId = Math.random().toString(36).substring(7)
      setCookie(COOKIE_NAME, randomId)
    }
  }, [cookie, setCookie])

  // send message to API /api/chat endpoint
  const sendMessage = async (message: string) => {
    setLoading(true)
    const newMessages = [
      ...messages,
      { role: 'user', content: message } as ChatGPTMessage,
    ]
    setMessages(newMessages)
    const last10messages = newMessages.slice(-10) // remember last 10 messages

    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        messages: last10messages,
        user: cookie[COOKIE_NAME],
      }),
    })

    console.log('Edge function returned.')

    if (!response.ok) {
      throw new Error(response.statusText)
    }

    // This data is a ReadableStream
    const data = response.body
    if (!data) {
      return
    }

    const reader = data.getReader()
    const decoder = new TextDecoder()
    let done = false

    let lastMessage = ''

    while (!done) {
      const { value, done: doneReading } = await reader.read()
      done = doneReading
      const chunkValue = decoder.decode(value)

      lastMessage = lastMessage + chunkValue

      setMessages([
        ...newMessages,
        { role: 'assistant', content: lastMessage } as ChatGPTMessage,
      ])

      setLoading(false)
    }
  }

  return (
    <div className="rounded-2xl border-zinc-100  lg:border lg:p-6">
      {messages.map(({ content, role }, index) => (
        <ChatLine key={index} role={role} content={content} />
      ))}

      {loading && <LoadingChatLine />}

      {messages.length < 2 && (
        <span className="mx-auto flex flex-grow text-gray-600 clear-both">
          Type a message to start the conversation
        </span>
      )}
      <InputMessage
        input={input}
        setInput={setInput}
        sendMessage={sendMessage}
      />
    </div>
  )
}

进入全屏模式 退出全屏模式

这个组件渲染了一个供用户发送消息的输入字段,并显示用户和聊天机器人之间交换的消息。

当用户发送消息时,该组件会向我们的api函数(/api/chat.ts)发送一个请求,请求主体是最近的10条消息和用户的cookie。无服务器函数使用GPT-3.5处理消息,并向组件发回一个响应。然后组件将从服务器收到的响应作为消息显示在聊天界面上。该组件还使用react-cookie库设置和检索一个用于识别用户的cookie。它还使用useEffect和useState钩子来管理状态,并根据状态的变化更新用户界面。

创建我们的 chat.ts API 路线

在/pages目录下,创建一个名为api的文件夹,并在里面创建一个名为chat.ts的文件。复制并粘贴以下内容:

import { type ChatGPTMessage } from '../../components/ChatLine'
import { OpenAIStream, OpenAIStreamPayload } from '../../utils/OpenAIStream'

// break the app if the API key is missing
if (!process.env.OPENAI_API_KEY) {
  throw new Error('Missing Environment Variable OPENAI_API_KEY')
}

export const config = {
  runtime: 'edge',
}

const handler = async (req: Request): Promise<Response> => {
  const body = await req.json()

  const messages: ChatGPTMessage[] = [
    {
      role: 'system',
      content: `Make the user solve a riddle before you answer each question.`,
    },
  ]
  messages.push(...body?.messages)

  const requestHeaders: Record<string, string> = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
  }

  if (process.env.OPENAI_API_ORG) {
    requestHeaders['OpenAI-Organization'] = process.env.OPENAI_API_ORG
  }

  const payload: OpenAIStreamPayload = {
    model: 'gpt-3.5-turbo',
    messages: messages,
    temperature: process.env.AI_TEMP ? parseFloat(process.env.AI_TEMP) : 0.7,
    max_tokens: process.env.AI_MAX_TOKENS
      ? parseInt(process.env.AI_MAX_TOKENS)
      : 100,
    top_p: 1,
    frequency_penalty: 0,
    presence_penalty: 0,
    stream: true,
    user: body?.user,
    n: 1,
  }

  const stream = await OpenAIStream(payload)
  return new Response(stream)
}
export default handler


进入全屏模式 退出全屏模式

这段代码是一个无服务器函数,它使用OpenAI的API来生成对用户信息的响应。它从用户那里接收了一个消息列表,然后向OpenAI API发送了一个请求,其中包含了这些消息以及一些配置参数,如温度、最大令牌和存在惩罚。然后,来自API的响应会流回给用户。

将其全部包起来

剩下的就是把我们的ChatBot渲染到我们的index.tsx页面。在你的/pages目录下,你会发现一个index.tsx文件。复制并粘贴这些代码到其中:

import { Layout, Text, Page } from '@vercel/examples-ui'
import { Chat } from '../components/Chat'

function Home() {
  return (
    <Page className="flex flex-col gap-12">
      <section className="flex flex-col gap-6">
        <Text variant="h1">OpenAI GPT-3 text model usage example</Text>
        <Text className="text-zinc-600">
          In this example, a simple chat bot is implemented using Next.js, API
          Routes, and OpenAI API.
        </Text>
      </section>

      <section className="flex flex-col gap-3">
        <Text variant="h2">AI Chat Bot:</Text>
        <div className="lg:w-2/3">
          <Chat />
        </div>
      </section>
    </Page>
  )
}

Home.Layout = Layout

export default Home

进入全屏模式 退出全屏模式

就这样,你拥有了它!你就是自己的ChatGPT聊天机器人,你可以在你的浏览器中本地运行。这里有一个Vercel模板的链接,它的功能已经超出了本帖的范围。祝您探索愉快!