用chatgpt写一个chatgpt(koa+react)

今天和大家分享一下如何使用 chatgpt 工具来开发一款 chatgpt 类应用。

先上效果图:

开发的过程中,你可以直接使用下面的地址或客户端查询你遇到的问题。

准备工作

OK,我们来快速创建一个 react 项目。不清楚的,可以问 GPT,如:

为了节省时间,我简单说一下比较关键的地方。如果遇到模块安装很慢的情况,也可以直接问 gpt:

安装 create-react-app

lua 复制代码
npm install -g create-react-app

使用 create-react-app 创建项目

lua 复制代码
npx create-react-app chatgpt

创建成功后,执行以下命令启动项目

sql 复制代码
npm start

随后就能看到以下页面:

得到一个比较干净的页面后,我们就可以开始进入主题了:

构建前端页面

从下图可以看出,页面主要分为 3 个部分:

接下来,我们让 chatgpt 帮我们写一下布局,如下图:

chatgpt 给出的 css 代码如下:

css 复制代码
html, body { 
    height: 100%; 
    margin: 0; 
    padding: 0; 
}
.container { 
    display: flex;
    flex-direction: column;
    height: 100%;
} 
.header { 
    height: 42px;
    background-color: #3498db;
} 
.body { 
    flex-grow: 1;
    background-color: #2ecc71;
} 
.footer { 
    height: 42px;
    background-color: #e74c3c;
}

我们把代码放到 App.css 文件中。

html代码片段如下:

html 复制代码
<div class="container">
  <div class="header">header</div>
  <div class="body">body</div>
  <div class="footer">footer</div>
</div>

我们把代码 App.js 文件中

目前的效果为:

body 之所以没有把 footer 撑到底部,是因为我们把 html 片段放到了 root 元素下,我们把它的高度设置成 100% 就行了。

稍微调整一下页面:

UI我们用到 antd 这个框架,安装一下:

css 复制代码
 npm i antd

调整后的效果:

至于中间的内容,为了节省时间,我们使用 antd 中 List 组件来展示数据,步骤如下:

  • 在 src 下新建名为 bizComponents 的文件夹;
  • 在 bizComponents 下新建 List 文件夹;
  • 在 List 下分别新建 index.css 和 index.js 两个文件;
  • List组件 的示例代码拷贝到 index.js 文件中。

如下图所示

图中的 js 代码如下:

javascript 复制代码
import React from 'react';
import { Avatar, List } from 'antd';

const data = [
  {
    title: 'Ant Design Title 1',
  },
  {
    title: 'Ant Design Title 2',
  },
  {
    title: 'Ant Design Title 3',
  },
  {
    title: 'Ant Design Title 4',
  },
];

const App = () => (
  <List
    itemLayout="horizontal"
    dataSource={data}
    renderItem={(item, index) => (
      <List.Item>
        <List.Item.Meta
          avatar={<Avatar src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`} />}
          title={<a href="https://ant.design">{item.title}</a>}
          description="Ant Design, a design language for background applications, is refined by Ant UED Team"
        />
      </List.Item>
    )}
  />
);
export default App;

在 app.js 中引入我们刚才写的 List 组件:

微调一下列表的宽度和位置:

最终效果:

我们接着来完成表单部分的开发:

  • 简单的表单校验:必填;
  • 键盘事件
    • enter,提交表单;
    • shift + enter,换行
  • 提交表单,获取表单数据

此时,app.js的完整代码:

ini 复制代码
import "./App.css";
import { Form, Input } from "antd";
import List from "./bizComponents/List";
import { useCallback, useState } from "react";

const { TextArea } = Input;

function App() {
  const [value, setValue] = useState("");
  const [form] = Form.useForm();

  const onFinish = useCallback(() => {
    const formValues = form.getFieldsValue();
    console.log("formValues:", formValues);
  }, [form]);

  const onchange = useCallback(
    (e) => {
      const value = e.target.value;
      setValue(value);
    },
    [setValue]
  );

  const onkeydown = useCallback(
    (e) => {
      const value = e.target.value;
      const keycode = e.keyCode;

      // 按下Shift+Enter时换行
      if (keycode === 13 && e.shiftKey) {
        setValue(value);
        return;
      }

      // 按下Enter时提交表单
      if (keycode === 13 && !e.shiftKey) {
        form.submit();
        e.preventDefault();
      }
    },
    [setValue, form]
  );

  return (
    <div className="container">
      <div className="header">header</div>
      <div className="body">
        <div className="list-wrapper">
          <List />
        </div>
      </div>
      <div className="footer">
        <Form form={form} onFinish={onFinish}>
          <Form.Item
            name={"message"}
            rules={[{ required: true, message: "请输入提示词" }]}
          >
            <TextArea
              value={value}
              autoSize={{ minRows: 1, maxRows: 15 }}
              onChange={onchange}
              onKeyDown={onkeydown}
            />
          </Form.Item>
        </Form>
      </div>
    </div>
  );
}

export default App;

至此,前端部分我们基本上就准备好了。接下来就是到 LLM(大语言模型)中获取数据了。

后端服务

api 调用

目前最火的大语言模型就是 openai 公司的 gpt 系列模型了,这里,我们以 gpt-3.5-turbo-0613 为例进行分享。

调用 gpt 系列的模型,主要有两种方式,一种是直接使用 openai api,另一种是使用 azure openai api。下面先简单说一下他们的区别:

openai api vs. azure openai api

openai api

openai 公司提供的官方 api。

优点:

  • 响应速度快,发起请求后,基本上是秒回;
  • 功能更新及时,因为是官方的api,有什么新功能、新特性,立马就能直接使用;
  • 功能、特性比较多,function_call、embedding、fine-tuning、dalle。

缺点:

  • 不对中国开放,中国 ip 无法访问;
  • 无法使用支付宝、微信支付、中国银行卡和信用卡进行支付。

azure openai api

由微软公司提供的 openai api 服务。

优点:

  • 可以使用支付宝、微信支付等方式进行支付;
  • 对中国大陆用户不做任何限制。

缺点:

  • 响应速度偏慢,即使开启了 stream 模式,发起请求后,平均下来,也要 7 秒左右才有返回;
  • 更新偏慢,每次 openai 发布新功能或新特性之后,要等好长一段时间才有;
  • 功能缺失,不是所有的功能都有,有的功能干脆就是阉割版。比如: 无法使用function_call,embedding 功能的最大并发数是1,不过 token 数提高到了 8k;
  • 只对企业用户开放,一般的企业用户,审核周期比较长,要 3 周左右的时间。

调用 openai api

打开终端,在 chatgpt 项目的同级目录下创建 chatgpt-server 文件夹

arduino 复制代码
mkdir chatgpt-server

初始化一下项目,进入 chatgpt-server,然后执行

csharp 复制代码
npm init

输入后,已知回车即可,初始化后,在当前目录下就生成了一个 package.json 文件。

服务端,我们主要使用 koajs,先安装几个我们会用到的模块:

css 复制代码
npm i koa koa-router -D

接下来,我们让 gpt 帮我们生成代码:

图中代码

nodejs 复制代码
const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/hello', (ctx, next) => {
  ctx.body = 'Hello World';
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

在 chatgpt-server 文件夹下创建 index.js 文件,把生成的代码粘贴进去。

为了方便调试,我使用 node-dev 来启动服务。我们先安装一下 node-dev 模块:

css 复制代码
npm i -g node-dev

安装完成后,启动服务:

复制代码
node-dev index.js

启动成功后,将看到以下输出(避免端口占用,我把端口号改成了3003):

访问 http://localhost:3003/hello, 如果看到以下响应即表示代码、服务都没问题了。

为了获取请求体里面的参数,我需要添加 koa-bodyparser 模块。

安装模块:

css 复制代码
npm i koa-bodyparser -D

引入模块:

此时,chatgpt-server.js 的代码为:

javascript 复制代码
const Koa = require('koa')
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')

const app = new Koa()
const router = new Router()

app.use( bodyParser())

router.get('/hello', (ctx, next) => {
  ctx.body = "Hello World";
})

app.use(router.routes()).use(router.allowedMethods())

app.listen(3003, () => {
  console.log('Server listening on port 3003')
})

添加新的接口:/generate

安装需要的新模块:

css 复制代码
npm i openai koa-sse-stream dotenv -D

添加新接口:/generate

使用 postman 直接请求我们的接口,其返回值如下:

此时,chatgpt-server.js 的完整代码为:

javascript 复制代码
const Koa = require('koa')
const Router = require('koa-router')
const { Configuration, OpenAIApi } = require('openai')
const bodyParser = require('koa-bodyparser')
const cors = require('@koa/cors')
const dotenv = require('dotenv')

dotenv.config() // 设置了之后,就可以通过 process.env 访问 .env 文件里面的内容

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
})

const openai = new OpenAIApi(configuration)

const app = new Koa()
const router = new Router()

app.use(cors()) // 支持跨域请求
app.use(bodyParser())

router.get('/hello', (ctx, next) => {
  ctx.body = 'Hello World'
})

router.post('/generate', async (ctx, next) => {
  const { messages } = ctx.request?.body
  const completion = await openai.createChatCompletion({
    model: 'gpt-3.5-turbo-0613',
    messages,
  })

  ctx.body = completion.data?.choices[0]
})

app.use(router.routes()).use(router.allowedMethods())

app.listen(3003, () => {
  console.log('Server listening on port 3003')
})

接下来,我们完成前端的部分。

修改 onFinish 方法:

添加获取数据的方法:

(图中的接口应该是 3003 )

图中的代码:

javascript 复制代码
const onFinish = useCallback(() => {
    const message = form.getFieldValue("message");

    getChatMessage([
      ...contexts,
      {
        role: "user",
        content: message,
      },
    ]);

    // 表单提交后清空输入框
    form.setFieldValue("message", "");
}, [form, contexts]);
javascript 复制代码
const [contexts, setContexts] = useState([]);

const getChatMessage = useCallback(async (contexts) => {
    const res = await fetch(`http://localhost:3003/generate`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        messages: contexts,
      }),
    }).then(async (res) => await res.json());

    setContexts([...contexts, res.message]);
}, []);

把数据传给 List 组件

调整后的 List 组件的代码:

javascript 复制代码
import React from "react";
import { Avatar, List } from "antd";

const App = ({ data }) => (
  <List
    itemLayout="horizontal"
    dataSource={data}
    renderItem={(item, index) => (
      <List.Item>
        <List.Item.Meta
          avatar={
            <Avatar
              src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`}
            />
          }
          title={item.role}
          description={item.content}
        />
      </List.Item>
    )}
  />
);
export default App;

提问后的效果:

调用 azure openai api

调用 azure openai api 有以下几种方法:

这里有个我在网上找到的 教程,有需要可以看看。

stream mode

直接调用 LLM,等待时间比较长,用户体验很差。所以,通常我们在连接 LLM 的时候,都会使用 stream mode,一有结果马上返回,然后以"打字机"效果,逐渐展示内容。

安装所需模块:

css 复制代码
npm i koa-sse-stream eventsource-parser showdown -D

在 chatgpt-server.js 中导入模块:

图中代码为:

php 复制代码
const sse = require('koa-sse-stream')
const { createParser } = require('eventsource-parser')

const sseMiddleware = sse({
  maxClients: 5000,
  pingInterval: 30000,
})

修改 /generate 接口的代码:

此时的代码为:

javascript 复制代码
router.post('/generate', sseMiddleware, async (ctx, next) => {
  const { messages } = ctx.request?.body

  let chunks = []

  const onParse = (event) => {
    if (event.data === '[DONE]') return
    if (event.type === 'event') {
      try {
        const data = JSON.parse(event.data)
        const text = data.choices[0].delta.content || ''

        ctx.sse.send({
          data: text,
          event: 'data',
        })
      } catch (e) {
        console.error('出错了:', e)
        ctx.sse.sendEnd({
          data: `出错了`,
          event: 'error',
        })
      }
    }
  }

  const parser = createParser(onParse)
  const completion = await openai.createChatCompletion(
    {
      model: 'gpt-3.5-turbo-0613',
      messages,
      stream: true,
    },
    {
      responseType: 'stream',
    }
  )

  completion.data.on('data', (chunk) => {
    try {
      const dataStr = chunk.toString()
      chunks.push(chunk)
      parser.feed(dataStr)
    } catch (e) {
      console.warn('出错了:', e)
    }
  })

  completion.data.on('end', () => {
    ctx.sse.sendEnd()
  })
})
kotlin 复制代码
data: {"id":"chatcmpl-7s6V06L16jQBLxgY3mDfdB6ZakMco","object":"chat.completion.chunk","created":1693129050,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]

接下来修改 App.js 的代码:

导入模块:

javascript 复制代码
import { createParser } from "eventsource-parser";

修改 getChatMessage 方法:

javascript 复制代码
const getChatMessage = useCallback(async (contexts) => {
    const res = await fetch(`http://localhost:3003/generate`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        messages: contexts,
      }),
    });

    let result = "";

    const onParse = (event) => {
      if (event.event === "data") {
        result += event.data;
        console.log("result:", result);
      }

      updateLastContext({
        contexts,
        context: {
          role: "assistant",
          content: converter.makeHtml(result),
        },
      });
    };

    const reader = res.body.getReader();
    const decoder = new TextDecoder("utf-8");
    const parser = createParser(onParse);

    while (true) {
      // 取值, value 是后端返回流信息, done 表示后端结束流的输出
      const { value, done } = await reader.read();

      if (done) break;

      try {
        let text = decoder.decode(value);
        parser.feed(text);
      } catch (e) {
        console.log("出错了:", e);
      }
    }
}, []);

代码中用到的 updateLastContext 的代码:

javascript 复制代码
const updateLastContext = useCallback(({ context, contexts }) => {
    const len = contexts.length;
    const lastContext = contexts[len === 0 ? 0 : len - 1];

    if (lastContext?.role !== "user" && contexts?.length > 0) {
      if (lastContext) {
        lastContext.content = context.content;
      }
      setContexts([...contexts]);
    } else {
      setContexts([...contexts, context]);
    }
}, []);

新建一个名为 utils.js 的文件,然后贴入以下代码:

javascript 复制代码
import showdown from "showdown";

export const converter = new showdown.Converter({
  tables: true,
  tasklists: true,
  parseImgDimensions: true,
  simplifiedAutoLink: true,
  strikethrough: true,
  emoji: true,
  underline: true,
  ghMentions: true,
  smartIndentationFix: true,
  smoothLivePreview: true,
});

在 app.js 中导入该方法:

javascript 复制代码
import { converter } from "./utils";

调整 bizComponents/List/index.js 的代码:

javascript 复制代码
import React from "react";
import { Avatar, List } from "antd";

const App = ({ data }) => (
  <List
    itemLayout="horizontal"
    dataSource={data}
    renderItem={(item, index) => (
      <List.Item>
        <List.Item.Meta
          avatar={
            <Avatar
              src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`}
            />
          }
          title={item.role}
          description={
            <div
              dangerouslySetInnerHTML={{
                __html: `${item.content}`,
              }}
            ></div>
          }
        />
      </List.Item>
    )}
  />
);
export default App;

至此,我们已经通过 stream mode 实现了打字效果。

接入 GPT4

前不久,openai已经开放了 gpt4 的权限,不需要排队,直接使用即可。整体的过程和使用 3.5 的时候一样。只要把模型换成 gpt4 就行了。修改 chatgpt-server.js 的代码:

javascript 复制代码
const completion = await openai.createChatCompletion(
    {
      model: 'gpt-4-0613',
      messages,
      stream: true,
    },
    {
      responseType: 'stream',
    }
)

值得一提的是,就算你使用的是 gpt4 的模型,当你询问它"你是GPT几"的时候,它依然会回答你,它是 gpt3.

曾经遇到的问题

流模式下的错误捕获

调用 openai api 的时候,是通过 try...catch 的方式捕获错误:

javascript 复制代码
try {
    ...
    const completion = await openai.createChatCompletion(
    ...
    )
    ...
} catch(e) {
    const { status, data = {} } = e.response || {}
    e.response.data.on('data', (data) => {
        const message = data.toString()
        console.log('error:', message)
    })
}

代码高亮(返回的内容中,代码部分不全,无法解析)

开启了流模式后,在内容返回的过程中,在某一段时间内,由于返回的内容不全,导致在将 markdown 转成 html 时,会出现异常(比如在展示代码的时候),导致没法正常展示,如下图所示

我的解决方案是,在渲染前,先判断是否有缺失"```":

javascript 复制代码
...
const getSemiFinishedContent = (string, identifier) => {
  let regex = new RegExp(identifier, 'img')
  let matches = []
  let match = null

  // 找到标识符所在的所有位置
  while ((match = regex.exec(string))) {
    matches.push(match.index)
  }

  // 找到半闭合标签的位置
  const matchLen = matches.length
  const num = matchLen % 2

  return {
    pos: num !== 0 ? matches[matchLen - 1] : -1,
    matchStr: num !== 0 ? string.slice(matches[matchLen - 1]) : '',
  }
}

// 处理一下半闭合的代码块内的标签转义问题(既然缺少"```",那就给它补上)
const semiFinishedInfo = getSemiFinishedContent(newResult, '```')
if (semiFinishedInfo.pos !== -1) newResult += '\n```'
...

stream mode 下的 token数计算

开启了 stream 模式后,openai 就不会再返回我们消费的 token 数的信息,需要自己手动计算。这里,我们用到了 tiktoken-node 这个模块:

javascript 复制代码
import tiktoken from 'tiktoken-node'

const enc = tiktoken.encodingForModel('gpt-3.5-turbo-0613')
enc.encode(str).length // 这里得到的就是 str 对应的 token 数
相关推荐
阿杆20 小时前
想体验出海应用赚钱?试试这个一年免费的香港服务器
后端·产品·创业
rocksun3 天前
仍在连接:新的史蒂夫·乔布斯故事不断涌现
创业
JavaBuild4 天前
时隔半年,拾笔分享:来自一个大龄程序员的迷茫自问
后端·程序员·创业
不简说2 个月前
程序员变现?这几天副业搞钱中的思考🤔
ai编程·创业
马可奥勒留2 个月前
我的管理日记(3)
创业
文火冰糖的硅基工坊2 个月前
[创业之路-343]:创业:一场认知重构与组织进化的双向奔赴
华为·架构·创业·公司·治理
嘟嘟MD2 个月前
程序员副业 | 2025年3月复盘
后端·创业
AntBlack3 个月前
DataWorks 体验笔记 :MaxCompute 用 Python 对数据进行二次处理
大数据·后端·创业
欧雷殿3 个月前
纯「牛马」的逻辑玩儿不转了!
程序员·求职·创业
AntBlack3 个月前
DataWorks 体验笔记 :一切的基础都是数据的读和写
大数据·后端·创业