随着人工智能和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
进入全屏模式 退出全屏模式
-
命名你的项目
-
选择TypeScript
-
选择Tailwind
-
为Git仓库选择Y
-
选择Y来运行pnpm安装
-
按回车键,输入默认的导入别名
现在我们有了一个启动的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
进入全屏模式 退出全屏模式
现在我们有了所有的模板,我们要做的是什么?让我们创建一个检查表:
-
我们需要能够监听来自OpenAI API的响应。
-
我们需要能够发送用户输入到OpenAI API。
-
我们需要在某种聊天用户界面中显示所有这些内容。
创建一个数据流
为了接收来自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以获得响应。
创建聊天机器人组件
如果我们想的话,我们可以在一个组件中创建我们的聊天机器人,但为了使文件更有条理,我们把它设置成三个组件。
在你的根目录下,创建一个名为组件的文件夹。在其中,创建三个文件:
-
Button.tsx
-
Chat.tsx
-
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模板的链接,它的功能已经超出了本帖的范围。祝您探索愉快!