Featured image of post 搭建一个简易的 ChatGPT

搭建一个简易的 ChatGPT

本篇文章带你使用 OpenAI 的 ChatGPT 模型搭建一个简易的 Web 聊天机器人

今天一觉醒来收到了邮件,OpenAI 释出了 ChatGPT 模型的 API。

之前开放的只有 GPT-3 的模型,它只能完成 Completion 式的会话,现在,我们终于可以进行 Chat 式会话了!

OpenAI 的邮件

接口使用

首先马上去学习了一下如何通过 API 使用这个模型,发现他对于会话式只需要使用你自己的 APIKey 来调用接口,同时携带上下文的数据即可。

一个请求消息体的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "model": "gpt-3.5-turbo-0301",
  "messages": [
    {
      "role": "user",
      "content": "你好,我在使用 API 调用你!"
    },
    {
      "role": "assistant",
      "content": "\n\n您好,我是 AI 语言模型,很高兴能够为您服务!请问您需要哪些 API 调用呢?"
    },
    {
      "role": "user",
      "content": "我想问问你可以调用网络 API 吗?"
    }
  ]
}

接口调用结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "id": "chatcmpl-6pogkJ74asx7oUZHuWN6SxxbGHN1o",
    "object": "chat.completion",
    "created": 1677807594,
    "model": "gpt-3.5-turbo-0301",
    "usage": {
        "prompt_tokens": 84,
        "completion_tokens": 78,
        "total_tokens": 162
    },
    "choices": [
        {
            "message": {
                "role": "assistant",
                "content": "是的,我可以调用网络 API。网络 API 是通过互联网提供服务的接口,可以让您的应用程序访问和处理远程服务器上的数据或功能。请告诉我您想要调用哪个网络 API,我会尽力帮助您。"
            },
            "finish_reason": "stop",
            "index": 0
        }
    ]
}

是不是很清晰?

实际调用还是类似于单次会话调用,只是需要把上下文的消息都传递进去,上下文模拟了一次对话,使用 role 来标识出不同的角色,content 来标识出不同的内容。

然后就可以得到 ChatGPT 根据上下文给出的结果了。

搭建一个简易的 ChatGPT

那么,知道了如何使用 API,我们就可以开始搭建一个简易的 ChatGPT 了。

技术选型

首先,我们需要选择一下技术栈,这里我选择了 Next.js + TypeScript。

因为调用 API 需要个人的 APIKey,为了保护这个 APIKey,我们需要在服务端进行调用,所以我们需要一个服务端渲染的框架。用户调用服务端的接口,服务端使用 APIKey 调用 OpenAI 的接口,然后把结果返回给用户。

搭建项目

首先,我们需要创建一个 Next.js 项目,这里我使用的是 Next.js 13 版本,因为并不是生产环境,所以体验一下最新技术。

根据 Next.js Beta 官网 说明使用脚手架创建项目:

1
npx create-next-app@latest --experimental-app

然后根据提示一步一步创建好项目,然后使用 IDE 打开项目。

开始开发

首先 pnpm i 安装依赖,然后 pnpm dev 启动项目,会发现模版里面已经写了一些东西了。

这里我们可以保留原先的框架内容,做几件事情就好:

  • /app/globals.css 中仅保留 htmlbody 的样式
  • /app/pages.tsxHome 组件中 main 中的所有内容删掉
  • /app/api/ 目录清空

然后就可以进行我们的应用开发了。

创建 API

我们在 /app/api/ 目录下创建一个 postMsg 文件夹,然后在 postMsg 文件夹下创建 route.ts 文件,这个文件就是我们的 API。在 url 中访问 http://127.0.0.1:3000/api/postMsg 就会访问到这个文件内的逻辑。

route.ts 中写入如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export async function POST(request: Request) {
  // 解析出请求体中的 messages 字段
  const { messages } = await request.json();
  
  // 按照 OpenAI 要求构造请求体
  const data = {
    messages,
    model: 'gpt-3.5-turbo-0301', // 使用 ChatGPT 模型
  };

  // 使用你的 API_KEY 调用 OpenAI 的接口
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `YOUR_API_KEY`,
    },
    body: JSON.stringify(data),
  });
  
  // 解析出 OpenAI 返回的结果并返回给应用
  const json = await response.json();
  return new Response(JSON.stringify(json));
}

这样我们就完成了一个简单的 API 了。在这个简易的应用里也只需要这一个 API。

创建页面

这里我们创建一个简单的页面,用来展示我们的应用。这个就比较简单了,写写样式就好了。这里我主要说一下逻辑。

有一个点需要注意一下,因为做得很简单,交互都在 Home 组件中,所以我们需要使用客户端组件,需要在文件顶部添加 'use client'

本地会话缓存

因为简易应用,自己使用,所以我们需要在前端保存会话的数据,这里我保存在了 localStorage 中。如果要实现 ChatGPT 的多会话功能,那么我们肯定需要同时存储多组会话数据,这里给出我的数据结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// localStorage 中保存两个数据,一个是会话列表,一个是当前会话的 key

// 当前会话的 key
currentKey: string;

// 会话列表
type RecordItem = {
  content: string;
  role: Role;
};
type RecordList = {
  [key: string]: RecordItem[];
};

这里就可以通过 currentKey 来获取当前会话的数据,然后通过 RecordList 来获取所有会话的数据了。

初始化会话

在页面初始化的时候,我们需要初始化会话,这里我们需要判断 localStorage 中是否有会话数据,如果没有,那么我们就创建一个新会话,如果有,那么我们就使用 localStorage 中的数据。

为了统一管理 localStorage 中的数据,我们需要定义一些常量:

1
2
3
// 会话列表的 key
const currentKeyKey = 'currentKey';
const recordListKey = 'recordList';

这里开始所有代码都在 Home 组件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 声明两个变量接收 localStorage 中的数据
let lsCurrentKey = '';
let lsRecordList: RecordList = {};

// 这里是为了防止打包时在服务端渲染时报错,需要放在这个判断中
if (typeof window != 'undefined') {
  // 如果是第一次使用,那么我们就创建一个新的会话,生成一个随机的 key 并存入 localStorage
  lsCurrentKey = window.localStorage.getItem(currentKeyKey) ?? '';
  if (!lsCurrentKey) {
    lsCurrentKey = `${new Date().toLocaleString().split(' ')[0]}_${String(Math.random()).slice(2, 6)}`;
    window.localStorage.setItem(currentKeyKey, lsCurrentKey);
  }
  
  lsRecordList = JSON.parse(window.localStorage.getItem(recordListKey) || '{}');
  if (!lsRecordList[lsCurrentKey]) {
    lsRecordList[lsCurrentKey] = [];
    window.localStorage.setItem(recordListKey, JSON.stringify(lsRecordList));
  }
}

// 然后将数据保存到 state 中进行响应式管理
const [currentKey, setCurrentKey] = useState<string>(lsCurrentKey);
const [recordList, setRecordList] = useState<RecordList>(lsRecordList);

// 这里创建了一个当前会话的 state,方便我们在渲染时使用
const [record, setRecord] = useState<RecordItem[]>(recordList[currentKey]);

主要逻辑

这里我们需要实现两个功能,发送消息和接收消息,同时更新 localStorage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 保存到 localStorage 中的方法
const saveToLocalStorage = () => {
  console.log('存入最新的记录');
  window.localStorage.setItem(currentKeyKey, currentKey);
  window.localStorage.setItem(recordListKey, JSON.stringify(recordList));
};

// 发送消息方法
const sendMsg = async (msg: string) => {
  if (msg) {
    setRecordList((prev) => {
      return {
        ...prev,
        [currentKey]: [...prev[currentKey], { role: 'user', content: msg }],
      };
    });
  } else {
    alert('请输入内容');
  }
};

// 当 recordList 或者 currentKey 发生变化时,更新 record 使页面重新渲染
useEffect(() => {
  saveToLocalStorage();
  setRecord(recordList[currentKey]);
}, [recordList, currentKey]);

// 当 record 发生变化时,判断情况进行请求
useEffect(() => {
  // 如果当前列表最后一条记录是用户发送的,就发送请求
  if (record[record.length - 1]?.role === 'user') {
    // 这里我实现了一个简单的 loading 效果,机器人会先展示一个思考中,然后再展示回复
    setLoading(true);
    console.log('请求了 ChatGPT');
    
    // 这里为了防止在请求过程中,用户切换了会话,所以这里需要保存一下当前的 key 以便请求返回结果后存入正确的会话中
    const key = currentKey;
    
    // 发送请求,消息列表放在 body 的 messages 字段中,与 API 中的解析方法一致
    fetch('/api/postMsg', {
      method: 'POST',
      body: JSON.stringify({ messages: record }),
      cache: 'no-store',
    })
      .then(async (data) => {
        // 根据上面提到过的 API 响应格式解析 ChatGPT 的回复数据
        const json = await data.json();
        const newMessage = json.choices[0].message;

        // 将新的消息存入会话中
        setRecordList((prev) => {
          return {
            ...prev,
            [key]: [
              ...prev[key],
              { role: newMessage.role, content: newMessage.content },
            ],
          };
        });
        
        // 关闭 loading
        setLoading(false);
     })
      .catch((err) => {
        console.log(err);
      });
  }
}, [record]);

其他功能

这里还有一些其他的功能,比如切换会话,删除会话等,简单放一下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 生成一个随机的 key,确保当前会话列表中没有重复的 key
const generateKey = (): string => {
  const key = `${new Date().toLocaleString().split(' ')[0]}_${String(
    Math.random()
  ).slice(2, 6)}`;
  if (recordList[key]) {
    return generateKey();
  }
  return key;
};

// 创建新会话并切换到新会话
const newChat = () => {
  const key = generateKey();
  setRecordList((prev) => ({ ...prev, [key]: [] }));
  setCurrentKey(key);
};

// 删除会话
// 这里我在会话列表中没有给当前会话提供关闭按钮,所以这里只是删除了会话,没有切换到其他会话
// 如果你给当前会话提供删除按钮,那么这里需要对 key === currentKey 的情况进行处理
const closeChat = (key: string) => {
  const newRecordList = { ...recordList };
  delete newRecordList[key];
  setRecordList(newRecordList);
};

// 切换会话
const switchChat = (key: string) => {
  setCurrentKey(key);
};

界面展示

以上已经实现了所有的功能,下面我们来看一下效果,相信你可以根据上面的示例和下面的效果图,很快就能实现一个聊天机器人。

多会话聊天界面- PC 多会话聊天界面- 移动

当然还有不少细节我实现了但没有提,比如:

  • 机器人响应是 markdown 格式,如何像上面展示出格式化数据
  • 聊天室中新消息自动滚动到底部
  • 会话列表适配移动端展开收起
  • 机器人响应的 loading 效果(下图)

多会话聊天界面- 移动

总结

因为我是给公司内部搭建的自用项目,对于样式我并没有进行太多的处理,也没有办法提供 demo 给大家试用。

但是相信你现在已经可以自己基于 ChatGPT 实现一个聊天机器人了,样式也可以根据自己的需求进行调整。

就这,拜拜~