likes
comments
collection
share

挑战全栈接入chatGPT实现智能聊天

作者站长头像
站长
· 阅读数 15

前言

今天是 6 月更文的最后一期啦,终于坚持到 30 天了,也是不容易的,几近难产的阶段,常常只能靠囤的文章养活 🤣,以后也要多多囤文啊,哈哈哈哈。 那么今天就来看看之前大火的 chatGPT 吧! 前面也说到过我们在项目中接入了 chatGPT 的聊天功能,看着项目中的 chatGPT 我也心痒痒,于是便跟着大家一起研究了一下 chatGPT 的接入,包括前面出过的一篇 流式传输,也是为了实现 chatGPT 聊天效果所做的准备。 那么这一次就挑战一下 vite + react + node + express 全栈一条龙实现一个智能聊天机器人吧。

前台 AI 聊天平台

一直想要学习 react 但是始终没有一个完整的项目,特别的是针对 react+ts 的开发经验更是少之又少。 这次就选用 react + ts + vite 来实现了,就当是一次小小的练手,再来慢慢熟练一下 react 的使用吧 😜。

创建项目

挑战全栈接入chatGPT实现智能聊天

项目运行后出现报错

  1. tsconfig.node.json

挑战全栈接入chatGPT实现智能聊天 从报错的提示信息可以知道 moduleResolution 是 TypeScript 中的一个选项,用于指定模块解析策略。 在 Vite + React 项目中,通常会将其设置为 "node" 或者 "classic"。

  • "node" 表示使用 Node.js 的模块解析策略,这意味着模块的查找顺序是从当前文件夹开始,向上逐级查找 node_modules 文件夹,直到根目录。这种方式适用于 Node.js 项目或者使用 CommonJS 规范的项目。
  • "classic" 表示使用经典的模块解析策略,这意味着模块的查找顺序是从当前文件夹开始,向上逐级查找,直到根目录。这种方式适用于使用 AMD 或者 UMD 规范的项目。

默认值为 "node"。

  1. tsconfig.json 文件报错

挑战全栈接入chatGPT实现智能聊天

有了上面的配置经验,我们将 moduleResolution 设置为 node 后仍有一个报错 挑战全栈接入chatGPT实现智能聊天 先来看看 allowImportingTsExtensions 到底是什么? 挑战全栈接入chatGPT实现智能聊天 配置 tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

配置 eslint

对 eslint 进行初始化配置

npm init @eslint/config

挑战全栈接入chatGPT实现智能聊天

.eslintrc.cjs 文件出现报错: module is not defined 挑战全栈接入chatGPT实现智能聊天 这可能是因为 js 代码中使用了 node 的模块系统,但是 eslint 配置文件中并没有声明使用 node 的模块。 需要在 eslint 配置文件中设置运行环境

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  ...
};

Prettier 校验

一般 ESLint 用于检测代码风格代码规范,Prettier 用于对代码进行格式化。 安装 Prettier

yarn add prettier -D

添加 eslint 校验规则

module.exports = {
  env: {
    browser: true,
    es2021: true,
  },
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: "latest",
    sourceType: "module",
  },
  plugins: ["react", "@typescript-eslint", "prettier"],
  rules: {
    "prettier/prettier": "error",
    "arrow-body-style": "off",
    "prefer-arrow-callback": "off",
  },
};

接下来在 package.json 的 script 中添加命令。

{
  "script": {
    "lint": "eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./"
  }
}

最后 运行 yarn lint

它将遍历 src/中的所有文件,并在每个找到错误的文件中提供详细日志,你可以手动打开这些文件并更正错误

运行后根据错误日志进行排查更正, 在我的项目中出现了以下报错: 挑战全栈接入chatGPT实现智能聊天 当出现 Warning: React version not specified in eslint-plugin-react settings. 报错时 通过在 .eslintrc.js 文件中的 settings 对象中添加以下内容来解决此问题:

module.exports = {
  ...

  settings: {
    react: {
      version: "detect",
    },
  },
};

这将告诉 eslint 检测 React 版本并自动设置。如果您使用的是不同的 React 版本,需要将 version 更改为您正在使用的版本号。

到目前为止 eslint 校验生效啦 😊

svg 文件引入

挑战全栈接入chatGPT实现智能聊天

yarn add vite-plugin-svg

已解决

挑战全栈接入chatGPT实现智能聊天

路由配置

react-router 已经更新到了 v6 版本与之前的 v5 相比有着翻天覆地的变化 react-router-dom 在 react-router 的基础上开发,react-router-dom 中的一些组件直接引用于 react-dom , 所以安装了 react-router-dom 后就不用再安装 react-router 了。

安装 react-router-dom

yarn add react-router-dom

路由功能 React-Router V6 版本常用路由组件和 hooks

组件作用说明
一组路由代替原有,所有子路由都用基础的 Router children 来表示
基础路由Router 是可以嵌套的,解决原有 V5 中严格模式,后面与 V5 区别会详细介绍
导航组件在实际页面中跳转使用
自适应渲染组件根据实际路由 url 自动选择组件
hooks作用说明
useParams返回当前参数根据路径读取参数
useNavigate返回当前路由代替原有 V5 中的 useHistory
useOutlet返回根据路由生成的 element
useLocation返回当前的 location 对象
useRoutes同 Routers 组件一样,只不过是在 js 中使用
useSearchParams用来匹配 URL 中?后面的搜索参数

通过 outlet 实现嵌套路由,V6 版本的 react-router 升级了原有嵌套路由写法,并且重新实现了 useNavigate 来替代 useHistory,整体上更加好理解。

import React from "react";
import "./App.css";
import { Outlet } from "react-router-dom";

function App() {
  return (
    <>
      <div className="main">
        <Outlet />
      </div>
    </>
  );
}

export default App;

在 main.tsx 中生命路由表,路由懒加载的方式设置页面组件

import React, { lazy } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import "./style/reset.css";

const Home = lazy(() => import("./page/home/index"));
const Detail = lazy(() => import("./page/detail/index"));

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      {
        path: "home",
        element: <Home />,
      },
      {
        path: "detail",
        element: <Detail />,
      },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <RouterProvider router={router} />
);

react 项目中可能出现的报错

找不到路径

找不到模块 ‘path’ 或其相对应的类型声明 找不到名称"__dirname" 挑战全栈接入chatGPT实现智能聊天 此时 path 模块是存在的,只是缺少 ts 的一些声明配置,因此安装关于 node 这个库的 ts 声明配置即可 解决方案

npm install @types/node --save-dev
  1. 缺少 React 引入

'React' must be in scope when using JSXeslint挑战全栈接入chatGPT实现智能聊天 为什么明明页面中没有用到 React 的地方我们也必须得引入 React 呢

这是因为 JSX 语法中虽然可能没有直接使用 React 相关的 API,但是在 bable 的转译后 JSX 代码最终会被编译成 React.createElement() 方法调用的形式,因此需要引入 React 库。

就像 react 的官方说的 JSX 元素只是调用 React.createElement(component, props, ...children) 的语法糖。

聊天界面样式

前台页面样式主要参考自一个浏览器插件 monica 挑战全栈接入chatGPT实现智能聊天 (chrome、edge 都能支持建议使用 edge,同时需要一个 google 的账号绑定) 这个样式是真的很喜翻惹 😊 ,还能支持文案创作哦,大家可以去玩玩看

import React, { useState } from "react";
import loaddingImg from "@/assets/loadding.svg";
import "./index.scss";
interface msgType {
  role: string;
  content: string;
}
function App() {
  const staticClass = "chat";
  const [message, setMessage] = useState("");
  const [chats, setChats] = useState<msgType[]>([
    { role: "assistant", content: "欢迎回来!您想聊些什么?" },
  ]);
  const [isTyping, setIsTyping] = useState(false);
  // 提交问题
  const chat = async (e: React.FormEvent<HTMLFormElement>, message: string) => {
    e.preventDefault();
    if (!message) return;
    setIsTyping(true);
    const msgs = chats;
    msgs.push({ role: "user", content: message });
    setChats(msgs);
    setMessage("");
    console.log(chats);
    // 调用后端服务提交接口数据
    ...
  };

  // 校验中文输入法,中文输入时不进行计数
  const [isComposition, setIsComposition] = useState<boolean>(true);
  function handleComposition(e: any) {
    if (e.type === "compositionstart") {
      // e.type === "compositionstart" 时为中文输入
      setIsComposition(false);
    } else {
      setIsComposition(true);
    }
  }

  // 对用户输入的字数进行限制
  const inputMaxLength = 100;
  // 输入框计数器
  const [curInputCount, setCurInputCount] = useState<number>(0);
  // 监听键盘 enter 事件 以及用书输入的字数统计
 	function setInputLength(e: any, maxLength: number, msg: string) {
    // 输入内容时回车发送
    if (e.keyCode == 13 && !e.shiftKey) {
      e.cancelBubble = true; //ie阻止冒泡行为
      e.stopPropagation(); //Firefox阻止冒泡行为
      e.preventDefault(); //取消默认换行
      if (message) {
        // enter 发送问题 清空输入框计数器
        chat(e, message);
        setCurInputCount(0);
        return;
      }
    }
    // 同时按下 回车 & shift ==>> 换行
    if (e.keyCode == 13 && e.shiftKey) {
      setMessage(message + "\n");
    }

    // 判断是否为中文输入法,中文输入时不统计字数
    if (!isComposition) {
      return;
    }
    if (msg.length > maxLength) {
      setMessage(msg.substring(0, maxLength));
    } else {
      setCurInputCount(msg.length);
    }
  }

  return (
    <div className="wrapper">
      <p className="title">人工智能-ChatGPT</p>
      <div className="content">
        {chats && chats.length
          ? chats.map((chat, index) => (
              <div
                key={index}
                className={`${staticClass} ${
                  chat.role === "user" ? "chat-user" : "chat-assistant"
                }`}
              >
                <div
                  className={
                    chat.role === "user"
                      ? "chat-user-content"
                      : "chat-assistant-content"
                  }
                >
                  <span>{chat.content}</span>
                </div>
              </div>
            ))
          : ""}
      </div>

      <div className={isTyping ? "" : "hide"}>
        <p>
          <img src={loaddingImg} alt="" className="loadIcon" />
        </p>
      </div>

      <form action="">
        <textarea
          className="question-input"
          name="message"
          autoComplete="off"
          value={message}
          placeholder="Type a message here and hit Enter..."
          onChange={(e) => setMessage(e.target.value)}
          onCompositionStart={(e) => {
            handleComposition(e);
          }}
          onCompositionEnd={(e) => {
            handleComposition(e);
          }}
          onKeyUp={(e) => setInputLength(e.target, inputMaxLength, message)}
        />
        <div className="textarea-counter">
          {curInputCount}/{inputMaxLength}
        </div>
      </form>
    </div>
  );
}
export default App;

由于 textarea 默认的回车事件是输入换行,所以需要监听键盘阻止 enter 的原生事件,

  • 将 enter 改为发送提交信息
  • enter + shift 为 \n 换行

对输入的字符进行计数:

  • 当非直接输入开始第一个按键的时候,触发 compositionstart 事件,
  • 非直接输入结束的时候触发 compositionend 事件,
  • 在直接输入情况下,这两个事件都不会触发。

通过 compositionstart、compositionend 的监听可以帮助我们判断当前的输入状态并统计

顺带出一个 scss 的样式,感兴趣的同学也可以写一个自己喜欢的样式 😜

html,
body {
  scroll-behavior: smooth;
  background-color: #f8fcff;
}
.wrapper {
  position: relative;
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  border-radius: 20px;
  height: 720px;
  max-width: 500px;
  margin: 20px auto;
  border: 1px solid #e5e8eb;
  background-color: #ffffff;
  overflow: hidden;
}
.title {
  font-size: 1.3em;
  line-height: 1.1;
  text-align: center;
  position: sticky;
  top: 0;
  background-color: #ffffff;
  padding: 15px 10px;
  border-bottom: 1px solid #e5e8eb;
}
main {
  max-width: 800px;
  margin: auto;
}
.content {
  padding: 20px 20px;
  height: 545px;
  overflow: auto;
  // 设置整个滚动条的宽度和高度
  &::-webkit-scrollbar {
    width: 5px;
    height: 5px;
  }
  // 设置滚动条的轨道背景颜色和边框圆角
  &::-webkit-scrollbar-track {
    background: #fff;
    border-radius: 5px;
  }
  // 设置滚动条的拖动块的背景颜色和边框圆角
  &::-webkit-scrollbar-thumb {
    background: #e3e3e3;
    border-radius: 10px;
  }
  // 设置鼠标悬停在拖动块上时的背景颜色
  &::-webkit-scrollbar-thumb:hover {
    background: #e3e3e3;
    cursor: pointer;
  }
  // 设置滚动条的边角背景颜色
  &::-webkit-scrollbar-corner {
    background: #e3e3e3;
  }
}
.content-inner {
  display: inline-block;
  white-space: pre-wrap;
}

.chat {
  display: block;
  width: 100%;
  margin: 16px 0;
  &-user {
    text-align: right;
    display: flex;
    flex-direction: row-reverse;

    &-content {
      display: inline-block;
      max-width: 100%;
      padding: 10px;
      background-color: #6e819c;
      color: #fff;
      display: inline-block;
      border-radius: 8px 8px 0 8px;
    }
  }
  &-assistant {
    text-align: left;
    &-content {
      position: relative;
      display: inline-block;
      max-width: 100%;
      padding: 10px;
      background-color: #f6f8fa;
      display: inline-block;
      border-radius: 8px 8px 8px 0;
    }
  }
}

.loading {
  text-align: center;
  margin-bottom: 20px;
}

.hide {
  visibility: hidden;
  display: none;
}
.loadIcon {
  width: 24px;
  height: 24px;
  animation: rotation 3s linear infinite;
}

@keyframes rotation {
  from {
    -webkit-transform: rotate(0deg);
  }
  to {
    -webkit-transform: rotate(360deg);
  }
}

.input-area {
  padding: 20px;
  text-align: center;
  position: sticky;
  bottom: 0;
  background-color: #f4f6f8;
}

.question-input {
  width: 100%;
  height: 80px;
  border: none;
  border-radius: 10px;
  resize: none;
  padding: 10px 10px 20px;
  font-size: 1 rem;
  background-color: #ffffff;
  &:focus {
    outline: none;
    background-color: #fff;
  }
  // 设置整个滚动条的宽度和高度
  &::-webkit-scrollbar {
    width: 5px;
    height: 5px;
  }
  // 设置滚动条的轨道背景颜色和边框圆角
  &::-webkit-scrollbar-track {
    background: #fff;
    border-radius: 5px;
  }
  // 设置滚动条的拖动块的背景颜色和边框圆角
  &::-webkit-scrollbar-thumb {
    background: #e3e3e3;
    border-radius: 10px;
  }
  // 设置鼠标悬停在拖动块上时的背景颜色
  &::-webkit-scrollbar-thumb:hover {
    background: #e3e3e3;
    cursor: pointer;
  }
  // 设置滚动条的边角背景颜色
  &::-webkit-scrollbar-corner {
    background: #e3e3e3;
  }
}

.current-response {
  position: absolute;
  display: inline-block;
  width: 2px;
  height: 14px;
  bottom: 13px;
  background-color: black;
  animation: 1s steps(1, start) blink infinite;
}

@keyframes blink {
  from {
    opacity: 0;
  }

  50% {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
.textarea-counter {
  position: absolute;
  bottom: 0;
  right: 30px;
  display: inline-block;
  color: rgba(144, 147, 153, 0.5);
}

react-markdown & 代码高亮

chatGPT 的用途当然少不了咨询开发中遇到的一些疑难杂症了,所以代码的展示对开发同学来说更是必不可少的了。 要让 markdown 中的代码呈现出代码块的样式并对特定语言进行语法高亮展示,则需要借助 react-markdown 、react-syntax-highlighter 这两个库:

  • react-markdown: 主要用于展示 Markdown 格式的文本
  • react-syntax-highlighter: 实现代码块的高亮效果

在 react 项目中通过这两个库实现了对话框中 markdown 文本的展示,并实现代码的高亮 库的安装

yarn add react-markdown react-syntax-highlighter

markdown 文本展示

react-markdown 实现原理:

react-markdown 将 markdown 的语法解析成 mdast (markdown 语法树),根据解析结果构建一个虚拟的 DOM 树,当文本内容发生变化时,react-markdown 会通过比较新旧虚拟 DOM 树的差异,只更新需要修改的部分,而不是重新渲染整个文本内容。这种方式可以提高渲染性能,减少不必要的计算和 DOM 操作。

react-markdown 的使用: react-markdown 的使用很简单,从库中引入 ReactMarkdown 组件,在需要展示的位置传入带有 markdown 语法标记的文本即可

import React from 'react'
import ReactMarkdown from 'react-markdown'

function index() {
  const content = `### 欢迎回来,你想聊些什么呢?\n
~~~js
console.log('It works!')
~~~`,
    return (
    	<div className="detailed-content">
        // markdown 解析内容
        <ReactMarkdown>
          {content}
        </ReactMarkdown>>
      </div>
  )
}


ReactMarkdown 组件中会将 markdown 的语法标记转为相应的 html 标签, 挑战全栈接入chatGPT实现智能聊天

### 你好 会转为相应的 <h3>标签

~~~js 代码块则会被 <code> 包裹 表示为一个代码片段,同时 react-markdown 会用类名的方式拼接代码的类型如 language-js, 为后续的高亮提供了便利。

tps:如果使用 ReactMarkdown 后页面的样式没有变化,可以检查一下项目中的 reset 样式,是否是在样式初始化时将标签的样式覆盖。

接下来看看高亮是怎么实现的吧

代码高亮

代码高亮的实现方式:从包中导出 SyntaxHighlighter 组件, 在组件的行内传入相应的属性配置代码块的样式,并传入代码块中展示的内容 组件支持的 Props 如下:

名称说明
language代码语言,定义根据选用的高亮主题显示的效果将有所不同
children需要高亮的代码
showLineNumbers是否展示行号
startingLineNumber起始行号
lineNumberStyle行号样式设置
style设置代码块的主题样式
PreTag代码块的最外层标签,用于代替默认 pre 标签的元素或自定义反应组件
wrapLines超长代码是否折行

通过 SyntaxHighlighter 使用实现代码高亮效果:

import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism";
function index() {
  const codeString = "(num) => num + 1";
  return (
    <SyntaxHighlighter language="javascript" style={dark}>
      {codeString}
    </SyntaxHighlighter>
  );
}

可以根据自己的喜好选择 react-syntax-highlighter 库提供的代码风格哦,赶快动手试试吧!!!

markdown + highlight 组件抽取

为了让代码的结构更加清晰,这里抽取了一个 Markdown 组件,将 markdown 的展示封装成一个单独的可复用的组件,降低页面的耦合性,利于后续的更新维护。

import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
  darcula, // 暗色
  twilight, // 暗色主题
  vs, //亮色主题
} from "react-syntax-highlighter/dist/esm/styles/prism"; // 代码高亮主题风格
// 根据自己的喜好在包目录下选择喜欢的主题

// darcula twilight 暗色主题
// vs 亮色主题

type tProps = {
  textContent: string;
  darkMode?: boolean; // markdown文本
};

const them = {
  dark: twilight,
  light: vs,
};

const Markdown = (props: tProps) => {
  const { textContent, darkMode } = props;

  return (
    <ReactMarkdown
      components={{
        code({ node, inline, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || "");
          console.log(inline, match);
          return !inline && match ? (
            <SyntaxHighlighter
              wrapLines={true} // 是否折行
              style={darkMode ? them.dark : them.light} // 设置代码块主题样式
              language={match[1]} // 需要语言类型 如cssjsxjavascript
              PreTag="div"
              {...props}
            >
              {String(children).replace(/\n$/, "")}
            </SyntaxHighlighter>
          ) : (
            <code className={className} {...props}>
              {children}
            </code>
          );
        },
      }}
    >
      {textContent}
    </ReactMarkdown>
  );
};

export default Markdown;

组件的使用

...
import Markdown from "@/components/markdown";

function App() {
  ...
  const [chats, setChats] = useState<msgType[]>([
    {
      role: "assistant",
      content: `### 欢迎回来,你想聊些什么呢?\n
~~~js
console.log('It works!')
~~~
      `,
    },
  ]);
  ...
  return (
    <div className="wrapper">
      ...

      <div className="content">
        {chats && chats.length
          ? chats.map((chat, index) => (
            <div
              key={index}
              className={`${staticClass} ${
                chat.role === "user" ? "chat-user" : "chat-assistant"
              }`}
              >
              <div
                className={
                  chat.role === "user"
                  ? "chat-user-content"
                  : "chat-assistant-content"
                }
                >
                //  Markdown 组件的使用,在对话框中展示 markdown 内容
                <Markdown textContent={chat.content} darkMode={false} />
              </div>
            </div>
          ))
        : ""}
      </div>

      ...
    </div>
  );
}
export default App;

来展示一下成果吧, markdown 的效果成功啦 !

挑战全栈接入chatGPT实现智能聊天

后台 node 服务搭建

前台的样式搞定了,那么下一步我们就进入到后台模块,这一次选择 nodejs + Express 搭建一个简单的后台。

创建后台项目

创建一个新的目录 node-chatAI 用于项目的管理开发 进入目录后初始化项目

npm init -y

创建一个 package.json 文件 并 es 的语法模式, 这使 ES6 模块导入语句的使用成为可能。

{
  "type": "module"
}

首先通过 express 搭建一个 node 项目 安装项目相关依赖并创建 index.js 文件配置 node 的后台服务

npm i express  body-parser cors

express:是一个流行的 Node.js Web 框架,提供了一组丰富的功能和工具,使得开发 Web 应用程序变得更加简单和高效。express 框架提供了路由、中间件、模板引擎等功能,可以用于构建各种类型的 Web 应用程序,如 RESTful API、单页应用程序和传统的多页应用程序等。express 已经成为 node.js 生态系统中最受欢迎的 Web 框架之一

body-parser:是 Node 中用于解析 HTTP 请求体中的数据。可以解析多种不同的请求体数据格式,如 JSON、urlencoded 和文本等。在 Node 项目中,当需要从 POST、PUT 或 DELETE 请求中获取请求体数据时,可以使用 body-parser 来解析数据并将其转换为 JavaScript 对象。

cors:是 Node 中用于处理跨域资源共享(CORS)问题。在 Web 应用程序中,浏览器会使用 CORS 机制来限制来自不同源的资源的访问。如果你在 Node 项目中需要访问来自不同源的资源,就需要使用 cors 中间件来处理 CORS 问题。cors 中间件可以帮助你设置响应头,以允许来自其他域的请求访问你的资源。

所以我们的 node 后台项目是基于 express 搭建的 Web 应用程序,并且可以方便地与其他 Node 模块集成。 在 index.js 中进行 后台项目的配置

import express from "express";
import bodyParser from "body-parser";
import cors from "cors";
//  创建后端服务
const app = express();
const port = 8000; // 设置服务端口

// 配置  HTTP 请求体 的解析方式
app.use(bodyParser.json());

app.use(cors());

// get 请求路由
app.get("/getChat", async (request, response) => {
  console.log(request.query);
  response.json({ code: 200, data: "success", status: "00000" });
});

// post 请求路由
app.post("/chat", async (request, response) => {
  console.log(request.body);
  response.json({ data: "success", code: 200, status: "00000" });
});

// 监听端口服务
app.listen(port, () => {
  console.log(`listening on port ${port}`);
});

通过命令运行项目

node index.js
或者
nodemon index.js // 热更新保存代码后自动更新

项目启动成功!!! 挑战全栈接入chatGPT实现智能聊天

通过 postman 测试接口

get 请求

挑战全栈接入chatGPT实现智能聊天 通过 request.query 可以获取到 get 的传参 挑战全栈接入chatGPT实现智能聊天

post 请求

在测试 post 接口时需要注意 body 传参的请求头设置, 我们可以看到 body 下的选项卡分别对应的不同形式的请求体数据 可以选择以下不同的数据格式来传递请求体数据:

  • form-data:用于上传文件或者二进制数据等,可以通过 key-value 的形式来传递数据。
  • x-www-form-urlencoded:用于传递表单数据,可以通过 key-value 的形式来传递数据。
  • raw:用于传递文本或 JSON 数据等,可以选择不同的编码格式(如 JSON、XML、HTML 等)传递数据。
  • binary:用于上传二进制文件或者流数据等。

body-parser 中间件也提供了对应的参数解析方法,用于解析不同格式的请求体数据。

  • bodyParser.json():用于解析 JSON 格式的请求体数据,将其转换为 JavaScript 对象。
  • bodyParser.urlencoded():用于解析 URL 编码格式的请求体数据,将其转换为 JavaScript 对象。
  • bodyParser.text():用于解析文本格式的请求体数据,将其转换为字符串。
  • bodyParser.raw():用于解析二进制格式的请求体数据,将其转换为 Buffer 对象。
  1. 当我们选择 raw 传参的方式时,必须将请求头设为 **Content-Type: application/json; charset=utf-8 **支持传输 json() 格式的请求数据

挑战全栈接入chatGPT实现智能聊天 此时我们在 node 服务端应设置对应的 bodyParser 方式解析收到的请求参数

app.use(bodyParser.json());

测试一下接口能不能跑通 挑战全栈接入chatGPT实现智能聊天 观察控制台的输出情况,成功啦,我们已经能获取到接口的数据啦!!! 挑战全栈接入chatGPT实现智能聊天

  1. 如果以表单形式传递参数时设置请求头为** Content-Type:application/x-www-form-urlencoded **

挑战全栈接入chatGPT实现智能聊天 设置 bodyParser 对应的解析方式

app.use(bodyParser.urlencoded({ extended: true }));

挑战全栈接入chatGPT实现智能聊天 挑战全栈接入chatGPT实现智能聊天

接入 OpenAI

为了让 node 能够与 OpenAI 建立连接,我们需要对 openai 的使用有所了解以便后续的开发。 openai:OpenAI 官网为 python 和 Node 环境提供了安装包,@openai/api 是 OpenAI API 的官方 Node.js 客户端。使用这个包可以轻松地与 OpenAI API 进行交互,以访问各种人工智能模型和算法。 这个包提供了一组简单的 API,可以让你使用 OpenAI GPT-3 等模型生成文本、回答问题、翻译文本和执行其他自然语言处理任务。 同时,还提供了一些辅助函数,用于管理 API 密钥、请求参数和响应数据等。

安装 openai

npm install openai

引入 openai 通过 node 服务端调用接口 OpenAI 的不同模型具有不同的 API 接口,需要根据所使用的特定模型来查看其相应的 API 接口文档,并了解如何使用它。

关键概念

在 OpenAI 中涉及到几个关键的概念:

prompt - 提示词

prompt 本质上是“编程”模型的方式,通常是通过提供一些说明或一些示例。这不同于为单一任务设计的大多数其他 NLP 服务,例如情感分类或命名实体识别。相反,完成和聊天完成端点可用于几乎任何任务,包括内容或代码生成、摘要、扩展、对话、创意写作、风格转换等。

token - 令牌

模型将文本分解为 token 来理解和处理。token 可以是单词或只是字符块。 在给定的 API 请求中处理的 token 数量取决于输入和输出的长度。根据粗略的经验,对于英文文本,1 个标记大约为 4 个字符或 0.75 个单词。

要记住的一个限制是,文本提示和生成的完成组合不能超过模型的最大上下文长度

而 OpenAI API 的计费主要是基于 API 请求的数量和每个请求中使用的 token 数量来计算的,其中 Token 的计算包含两部分:

输入给 GPT 模型的 token 数和 GPT 模型生成文本的 token 数。

与中文回复相比:英文回复消耗的 Token 数量较少,响应速度更快。 挑战全栈接入chatGPT实现智能聊天

model - 模型 OpenApi 由一组具有不同功能和价位的模型提供支持。GPT-4 是我们最新、最强大的模型。GPT-3.5-Turbo 是为 ChatGPT 提供支持的模型,并针对对话格式进行了优化。 挑战全栈接入chatGPT实现智能聊天 当然 GPT-4 相较于 GPT-3.5 的功能是更为强大的,但是 GPT-4 目前处于有限的测试阶段,只有被授予访问权限的人才能访问,所以主要以 GPT-3.5 为主讨论一下 text-davinci-003 和 gpt-3.5-turbo 模型。

GPT-3.5 能够理解和生成自然语言有以下几种主要模式

GPT-3.5 模型版本描述相关数据
gpt-3.5-turbo能力最强大的模型,针对回话进行了优化并且成本只有 text-davinci-003 的十分之一,他将与最新的模型一同迭代更新。
最大令牌量 为 4096
训练的截止时间至 2021 年 9 月
gpt-3.5-turbo-03012023 年 3 月 1 日 gpt-3.5-turbo 的快照。与 gpt-3.5-turbo 不同的是,该型号将不接受更新,并且只支持三个月的时间,截止到 2023 年 6 月 1 日最大令牌量 为 4096
训练的截止时间至 2021 年 6 月
text-davinci-003与 Curibe、Babbage、Ada 模型相比可以支持更长的上下文,更高质的完成对话任务,还支持在文本中插入补全最大令牌量 为 4097
训练的截止时间至 2021 年 6 月
text-davinci-002能力与 text-davinci-003 类似,但是是通过监督微调进行训练,而非是强化学习最大令牌量 为 4097
训练的截止时间至 2021 年 6 月
code-davinci-002优化代码生成能力最大令牌量 为 8001
训练的截止时间至 2021 年 6 月

相较而言更推荐使用 GPT-3.5 -turbo 因为它的成本更低,

GPT-3.5-turbo 模型的 token 消耗量仅为 text-davinci-003 模型的 1/10。使用 GPT-3.5-turbo 模型可以更经济地生成文本,同时仍然保持高质量和准确性。当然具体情况具体分析,根据需求选择更适合的模型。

由于 OpenAI 的模型的不同接口调用方式也会有所差异

接口适用模型
/v1/chat/completionsgpt-4, gpt-4-0314, gpt-4-32k, gpt-4-32k-0314, gpt-3.5-turbo, gpt-3.5-turbo-0301
/v1/completionstext-davinci-003, text-davinci-002, text-curie-001, text-babbage-001, text-ada-001, davinci, curie, babbage, ada
/v1/editstext-davinci-edit-001, code-davinci-edit-001
/v1/audio/transcriptionswhisper-1
/v1/audio/translationswhisper-1
/v1/embeddingstext-embedding-ada-002, text-search-ada-doc-001
/v1/moderationstext-moderation-stable, text-moderation-latest

接口调用

通过实例化 openai 对象来调用 API

import { Configuration, OpenAIApi } from "openai";

const configuration = new Configuration({
  organization: "org-7BUWi7KiYgZQx6m6j4hziNps",
  apiKey: "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",
});
const openai = new OpenAIApi(configuration);

async function callOpenAI() {
  const result = await openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: [
      {
        role: "system",
        content: "You are a EbereGPT. You can help with graphic design tasks",
      },
      {
        role: "user",
        content: "你好!!!",
      },
    ],
  });

  console.log(completion.data.choices[0].message);
}

callOpenAI();

挑战全栈接入chatGPT实现智能聊天接口调用成功 !下一步就将 openAI 接入到我们的接口中以提供给前台项目使用。

实现前后台的接口调用

相信看到这里的同学们都已经知道该怎么做了吧 将 openAI 的接口调用写入所在 post 请求中,为前台提供接口服务

app.post("/chat", async (request, response) => {
  const { chats } = request.body;
  console.log(chats);
  const result = await openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: [
      {
        role: "system",
        content: "You are a EbereGPT. You can help with graphic design tasks",
      },
      ...chats,
    ],
  });
  console.log(result.data);
  response.json({
    output: result.data.choices[0].message,
  });
});

在前台配置接口的传参

...
// 输入完毕  提交问题
const chat = async (e: any, message: string) => {
  e.preventDefault();
  if (!message) return;
  // loadding
  setIsLoading(true);
  const msgs = chats;
  msgs.push({ role: "user", content: message });
  setChats(msgs);
  setMessage("");

  // 调用后台服务接口
  fetch("http://localhost:8000/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      chats,
    }),
  })
    .then((response) => response.json())
    .then((data) => {
      console.log(data);
      if (data.output) {
        msgs.push(data.output);
        setChats(msgs);
        setIsLoading(false);
      }
    })
    .catch((error) => {
      console.log(error);
    });
};
...

浅试一下

来试试问几个问题吧

挑战全栈接入chatGPT实现智能聊天

这里提出了一个算法问题,接口调试连通,并且 ChatGPT 能够正常响应接口调用,并通过后台服务将结果转发回前端。此外,返回的代码将通过 markdown 格式在前端页面上展示。 挑战全栈接入chatGPT实现智能聊天

到这里 chatGPT 最基本的聊天功能已经可以实现啦 😆,当然还需要持续的优化,

特别是在等待接口的过程中页面的状态一直处于 loadding 状态,直到接口完全返回数据后才将问题的回答渲染到页面中,这一点还是无法做到像官方一样的打字机效果,所以下一步就要继续研究 openAI 的流式输出 !

流式传输实现打字机效果

首先来看看打字机效果到底长啥样的 挑战全栈接入chatGPT实现智能聊天

看到这里来思考一下这种效果该如何实现呢?

在我们第一眼看到这种效果的时候有的同学可能会想到用定时器去实现 使用定时器(如 setInterval() 函数)控制每个字符的出现的间隔,从而实现每个字逐个显示的效果 这里我也放了一个实现的案例

import React, { useEffect } from "react";
import "./index.scss";

export default function Detail() {
  useEffect(() => {
    const element = document.getElementById("typewriter") as HTMLElement;
    const text =
      "雨过白鹭州,留恋铜雀楼,斜阳染幽草,几度飞红,摇曳了江上远帆,回望灯如花,未语人先羞。";
    let i = 0;
    const typing = setInterval(() => {
      if (i > text.length) {
        console.log("输入完成");
        clearInterval(typing);
        // 输入完成取消光标输入样式
        element.setAttribute("data-attr", "");
      } else {
        element.setAttribute("data-attr", "|");
        element.textContent = text.slice(0, i);
        i++;
      }
    }, 200);
  });
  return <div id="typewriter" data-attr="|"></div>;
}


 /* scss  实现光标闪烁的效果 */
 #typewriter::after{
  display: inline-block;
  content: attr(data-attr);
  color:black;
  animation:  0.5s blink infinite  step-end ;
}

@keyframes blink{
  from{
    opacity: 0;
  }

  50% {
    opacity: 1;
  }
  to {
    opacity: 0;
  }

}

虽然通过 定时器+css 能实现打字机的效果,但是这样可能并不理想,效果过于生硬,因为 chatGPT 生成结果的需要时间,逐个打字也需要时间,这可能会造成用户的等待过长,影响体验感。

但是别着急,chatGPT 的接口也为我们提供了流式传输的设置方式,为了更快地获得响应,可以在请求 API 时设置 stream=True,这将返回一个对象,以 data-only server-sent events 流式返回响应。

什么是流式传输?

在解决问题之前,我们需要了解什么是流式传输。流式传输指的是将数据分成多个数据流,通过网络传输,以减少网络延迟和提高性能。在某些情况下,流式传输也可以用于将视频流和音频流传输到客户端。流式传输是一种高效的数据传输方式,常用于大文件下载和在线视频播放等场景。

关于流式传输可以参考之前出的这篇文章 什么是流式传输 ?😜

在 node 中使用 chatGpt 的流式传输并将响应的结果转发至接口的响应流返回

app.post("/chatStream", async (request, response) => {
  const { chats } = request.body;
  const YOUR_API_KEY = "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";

  const result = await axios({
    url: "https://api.openai.com/v1/chat/completions",
    method: "post",
    data: {
      model: "gpt-3.5-turbo",
      stream: true, // 设置 chatGPT 接口数据为流式传输
      messages: [
        {
          role: "system",
          content: "You are a EbereGPT. You can help with graphic design tasks",
        },
        ...chats,
      ],
      max_tokens: 100,
    },

    headers: {
      Authorization: "Bearer " + YOUR_API_KEY,
      "Content-Type": "application/json",
    },
    responseType: "stream", // 设置 服务器响应的数据类型 为流式数据
  });

  // 流数据处理, 将可读流的数据输出到可写留
  // 将 chatGPT 返回的结果写入接口的响应流 返回给前端
  result.data.pipe(response);
});

react 项目中的接口调用,解析流式数据

 // 输入完毕  提交问题
  const chat = async (e: any, message: string) => {
    ...

    // 调用后台服务接口
    const response = await fetch("http://localhost:8000/chatStream", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "*,application/json",
      },
      body: JSON.stringify({
        chats,
      }),
    });

    setIsLoading(false);
    setCursor(true);
    // 准备将数据push到队列
    msgs.push({
      role: "assistant",
      content: "",
    });
    setChats(msgs);
    const encode = new TextDecoder("utf-8");
    const reader = response.body?.getReader() as any;
    let answer = "";
    const flag = true;
    //  流式数据的处理
    while (flag) {
      const { done, value } = await reader.read();
      console.log(value);
      if (done) {
        setCursor(false);
        break;
      }
      const text = encode.decode(value);

    }

当我们在调用接口时可以观察到获取到的相应的流式数据正在一次次的拼接,直到数据传输完毕,此时就需要对获取到的流式数据进行解析 挑战全栈接入chatGPT实现智能聊天 通过 console.log(value); 输出 能发现此时获取到的流式数据都是 挑战全栈接入chatGPT实现智能聊天

// 输入完毕  提交问题
  const chat = async (e: any, message: string) => {
    ...
    while (flag) {
      const { done, value } = await reader.read();
      console.log(value);
      if (done) {
        setCursor(false);
        break;
      }
      const text = encode.decode(value);
      const dataObj = JSON.parse(text.split("data: ")[1]).choices[0].delta;
      if (dataObj && dataObj.content) {
        answer += dataObj.content;
      }
      msgs[msgs.length - 1].content = answer;
      setChats(msgs.slice()); // 通过将副本传递给 setChats 来触发重新渲染
    }
  };

使用 msgs.slice() 创建了 msgs 数组的副本并传递给 setChats,以确保触发重新渲染。

nginx 配置

在项目上线的过程中,我们也遇到了 nginx 的坑。

因为我们的线上项目是通过 nginx 进行代理配置,nginx 默认开启缓存,导致流式输出到 nginx 时会被缓存,使得前端在获取结果时数据是一次性返回的,最终失去了打字机的逐字打印效果。

同时在项目的打包配置中 gzip 也可能会对数据的流式传输有影响。

这里可以通过 nginx 配置,把 gzip 和 缓存 都关掉。

server {
  listen       8080;
  server_name  localhost;
  location / {
      proxy_set_header   Host             $host;
      proxy_set_header   X-Real-IP        $remote_addr;
      proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
      proxy_cache off;
      proxy_cache_bypass $http_pragma;
      proxy_cache_revalidate on;
      proxy_http_version 1.1;
      proxy_buffering off;
      proxy_pass http://xxx.com:1234;
  }
}

当使用 Nginx 作为反向代理服务器时,**proxy_cache proxy_buffering **是两个重要的代理模块,用于优化性能和提高用户体验。

  • proxy_cache 模块:用于缓存来自后端服务器的响应,并将其存储在 Nginx 服务器上,以便在将来的请求中快速响应。它的主要目的是减轻后端服务器的负载,并通过减少响应时间来提高性能。对于静态或者不经常变动的内容非常有效,比如图片、CSS 和 JavaScript 文件等。

需要注意的是,使用 proxy_cache 模块时需要谨慎配置缓存策略, 根据需要设置缓存的参数,如缓存的有效时间、缓存的大小等,避免出现缓存不一致或者过期的情况。

  • proxy_buffering 模块:用于控制 Nginx 是否应该缓冲响应数据。默认情况下,Nginx 会缓冲响应,以便在完全接收响应之后再将其发送给客户端。这样可以提高性能,更好地管理与客户端之间的连接,减少代理服务器和客户端之间的网络连接数,提高并发处理能力,同时也可以防止后端服务器过早关闭连接,导致客户端无法接收到完整的响应数据。。

然而在某些情况下,需要禁用缓冲,特别是当后端服务器响应时间较长或者需要实时流式传输数据时。 通过禁用缓冲,Nginx 会立即将接收到的响应数据转发给客户端,而无需等待完整的响应。

实测只配置 proxy_cache 没有用,配置了 proxy_buffering 后流式输出才生效。

总结

到此我们的聊天功能算是基本实现啦,当然其中还有许多瑕疵的:

例如 chatGPT 在回复时的流式输出没有搭配上光标的闪烁效果,这还是差了点意思 🤣,目前没有很好的实现。 还有用户提问时的并发问题也没有去考虑,当然这次只是单纯的实现一问一答的聊天效果,比实际成熟的聊天软件还是差了一大截。

如果有更好的实现方式也欢迎大家及时提出哦 😉。

参考

  1. 搭建一个 Vite+React+TS+ESLint+Prettier+Husky+Commitlint 项目
  2. 如何用 OpenAI、ChatGPT、Node.js 和 React 搭建一个 AI 聊天机器人
  3. OpenAI 文档翻译——场景的模型以及差异
  4. react 项目实现预览 markdown,以及代码高亮
  5. OpenAI 中文文档
  6. 从零开始:实现 ChatGpt 打字机聊天效果
  7. 使用 node 和 js 对接 chatgpt,支持流式传输
  8. 如何实现 ChatGPT 的打字机效果
  9. 用 express.js 实现流式输出 HTTP 响应