用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 数
相关推荐
产品人卫朋1 小时前
硬件IPD开发的顺序,正在被AI倒过来
知识图谱·产品经理·创业·ipd流程
IT·陈寒2 天前
JavaScript实战技巧总结
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒5 天前
Redis 连接池泄漏害我加班到凌晨三点
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒6 天前
我的React组件莫名其妙重新渲染了8次
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒7 天前
Python的GIL问题又把我坑惨了
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒7 天前
React状态更新为啥有时吞了我的变更?
人工智能·大模型·api·创业·变现·简历优化
wangruofeng8 天前
马斯克和乔布斯的目标近乎妄想,Dan Koe 拆解「现实」目标为何通向平庸
创业·掘金翻译计划
IT·陈寒8 天前
Redis内存暴涨时,我忘记检查这个参数
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒9 天前
JavaScript的事件冒泡让我debug到凌晨三点
人工智能·大模型·api·创业·变现·简历优化
IT·陈寒10 天前
Vue组件props传对象给我整不会了
人工智能·大模型·api·创业·变现·简历优化