演示

项目地址

如果该项目对你有帮助,请点个star支持下吧!

  1. 线上体验地址:https://www.wang-xiaowu.site/chat-gpt/
  2. 仓库地址:https://github.com/behappy-project/behappy-chatgpt-assistant
  3. 技术沟通群二维码:https://raw.githubusercontent.com/wang-xiaowu/picture_repository/master/behappy_group.jpg

移动端

pdf格式无法展示动图:可点击该地址进行查看

PC端

pdf格式无法展示动图:可点击该地址进行查看

教程实现效果

pdf格式无法展示动图:可点击该地址进行查看

前置准备

openai

注册

  1. 关于注册账户可以查看我的这篇文章
  2. 不建议花钱买账户,一个是不确保它稳定。二来你花点时间熟悉这个东西也是帮助你长进的过程

API_KEY

  1. 地址:https://platform.openai.com/account/api-keys
  2. 登录点击个人头像,进入View API keys
  3. 首次进入应该是空的,点击Create new secret key进行创建
  4. 记录下你创建的key,因为之后就没办法找回了

Api文档

  1. 文档地址:https://platform.openai.com/docs/api-reference

  2. 文档中介绍了一些api的使用样例,包括但不仅限于:

    • 列举支持的数据模型

    • 聊天

    • 图片功能

    • 音频识别

    • ......

  3. 文档中也列举了python和nodejs的代码使用样例,请求时需要带着认证header,如下格式:Authorization: Bearer OPENAI_API_KEY

Chat Api的补充说明
  • model参数,我们需要使用的数据模型,案例中我们使用gpt-3.5-turbo

  • messages参数,主要输入是消息参数。消息必须是一个对象数组,其中每个对象都有一个rolesystemuserassistant)和content(消息的内容)。对话可以只有1条信息,也可以有许多。通常情况下,一次会话以system消息开头,然后是交替出现的userassistant消息。system消息帮助设置assistant的行为。在官方文档的示例中,使用You are a helpful assistant.指示了assistant。后续的prompt优化就可以围绕着这部分来实现

让我们再多看几个例子来弄懂这几个角色是干嘛的

  • 第一个请求我们使用assistant角色问它哪只球队是2015年冠军,它帮我们列出了四大联盟各自的总冠军,这个答案是没问题的
  • 第二个请求我们使用system为它添加人设(测试发现gpt3.5并不总是关注system的预设行为,这一点官网也有指出),可以看到它现在只关注NBA
  • 第三个请求我们使用user,以用户身份问它谁是2022年总冠军。我们知道gpt3.5的数据模式是截止到2021年9月份,所以问它2022年的总冠军它肯定不知道是谁
  • 所以第四个请求我们使用assistant角色,它是用来存储先前的响应信息的。这一类消息也可以由我们编写,以帮助给予它所需要的信息。

我们用到最多的可能就是聊天api,这里还有涉及到的参数如temperaturetop_pn等都会影响到你的输出结果,所以我们需要仔细了解这些参数的含义,才能更好的结合messages输出我们想要的结果

其他

  1. 可能会遇到登录不上的问题,例如遇到429限流,或者access denied等等,这类错误都是节点问题,此文档编辑于4.6,测试台湾节点可用
  2. 关于使用量:可以点击此地址查看

vercel

为什么使用vercel

  1. vercel是一个网站托管服务
  2. 其不仅支持静态网站,还支持部署serverless接口,意味着我们可以利用它部署我们的服务端项目。但需要记住Serverless 架构通常是无状态、不可变和短暂的。所以不要指望它放些内存数据
  3. 最主要的是:他免费!(这是我们用它的主要原因)
  4. 官方支持使用Nodejs 、 Go 、 Python 、 Ruby这几种语言来编写serverless接口,下文我们将会使用Nodejs编写服务端。

注册

  1. 这里没啥特殊的,使用github账户注册就好

其他

  1. 待会我们开发项目时,需要额外配置个vercel.json文件,会在部署vercel时用到。见:https://vercel.com/docs/concepts/projects/project-configuration
  2. 我们还需要用到vercel提供的cli,用来帮助我们测试、部署,相关指令见:https://vercel.com/docs/concepts/deployments/overview
  3. vercel自带一些环境变量,当然我们也可以配置运行时环境变量,见:https://vercel.com/docs/concepts/projects/environment-variables
  4. 既然是免费的,当然不会让我们无限用,所以vercel针对免费用户以及付费用户都会有些限制,见:https://vercel.com/docs/concepts/limits/overview
  5. vercel在部署好项目之后,会提供地址供我们访问。但自2021年5月起,这个地址已经无法在国内访问,但官方也给出了解决方案,见:

Nodejs

  1. nodejs官网:https://nodejs.org/en/download

  2. 也可考虑使用nvm安装,方便版本管理。可参考这篇文章

IDE

  1. 安装支持Nodejs的IDE
  2. idea或者vscode皆可

aardio

  1. 官网地址:https://www.aardio.com/
  2. aardio由国内大佬开发,专注桌面端应用。aardio有很好的扩展性,你可以使用它调用C,调用js,调用java...但作者老婆似乎病了,aardio也不打算再维护了,后续大概率就是逐渐被遗忘...我们这里不需要过多学习aardio的语法,全程写html+js,仅用它来发布exe程序即可。(btw,希望作者的老婆能好起来)
  3. aardio下载好,安装即可。不要放在带有中文或者空格的路径下

技术选型

功能技术栈版本
服务端语言Nodejs18.x
服务端web框架Koa2.x
客户端语言aardiolatest
客户端UI框架ChatUI2.4.2
JavaScript 库jQuery3.6.4

服务端开发

创建项目

  1. 创建个空项目,命名chatGpt-project,创建好后cmd进入根目录
  1. 初始化package.json,执行npm init,按需配置,这里我们一路默认即可,得到这样的一个文件

安装依赖

依赖功能介绍
@koa/cors解决跨域问题
koa-body解析HTTP请求体
koa-router路由配置
openaiopenai官方提供
tunnel配置代理
eslint-xxx代码格式化
esm可以使用 importexport 关键字
  1. 安装运行时依赖,执行npm i -S @koa/cors@4.0.0 @vercel/node@2.10.2 fs@0.0.1-security koa@2.13.4 koa-body@4.2.0 koa-router@12.0.0 openai@3.2.1 tunnel@0.0.6 axios@0.21.1 esm@3.2.25

  2. 安装开发依赖:执行npm i -D eslint@7.30.0 eslint-config-airbnb@18.2.1 eslint-plugin-import@2.23.4

目录结构建设

  1. 分别创建bin,config,lib,routes文件夹,如下

    bin:存放我们启动脚本

    config:存放我们的配置文件

    lib:存放我们待会会用到的第三方包,我们封装下

    routes:存放路由

代码开发

  1. 创建bin/run文件作为启动脚本,内容如下

    1
    2
    3
    #!/usr/bin/env node
    require = require('esm')(module);
    require('../index');
  1. 创建config/config.json文件,作为我们的配置文件,将一些配置信息配置在里面,启动的时候替换value
1
2
3
4
5
6
7
8
9
{
"sys": {
"port": "SYS_PORT"
},
"chatGpt": {
"host": "CHAT_GPT_HOST",
"key": "OPEN_API_KEY"
}
}
  1. 创建config/local.json文件,作为我们本地开发环境的配置文件(注:port不要配置为3000,待会儿会用到vercel,其启动端口默认占用3000)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "sys": {
    "port": "4000"
    },
    "chatGpt": {
    "host": "https://api.openai.com/v1",
    "key": "替换成你的API_KEY"
    }
    }
  2. 创建config/index.js文件,启动项目时加载,执行替换配置信息

    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
    import {existsSync} from 'fs';
    import {name} from '../package.json';

    // 配置自检
    export const envCfg = (() => {
    // 加载本地配置
    console.log(`环境: ${process.env.NODE_ENV}`);
    if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
    console.log(`加载本地开发环境...`);
    if (existsSync(`${__dirname}/local.json`)) {
    const conf = require('./local.json');
    console.log(`${name} 服务配置参数加载成功`);
    return conf;
    }
    console.error(`${name} 服务配置自检未通过,服务已停止`);
    process.exit();
    }

    // 非本地开发环境
    const conf = require('./config.json');

    // 配置自检
    const checkProperty = (cfgNode) => {
    let result = true;
    for (const pro of Object.keys(cfgNode)) {
    if (typeof cfgNode[pro] === 'object') {
    result = checkProperty(cfgNode[pro]) && result;
    continue;
    }

    // 服务调用参数配置
    if (!process.env[cfgNode[pro]]) {
    console.error(`参数: ${cfgNode[pro]} 未设置.`);
    result = false;
    } else {
    cfgNode[pro] = process.env[cfgNode[pro]];
    result = result && true;
    }
    }

    return result;
    };

    if (!checkProperty(conf)) {
    console.error(`${name} 服务配置自检未通过,服务已停止`);
    process.exit();
    } else {
    console.log(`${name} 服务配置参数加载成功`);
    return conf;
    }
    })();

    // 系统配置
    export const sysCfg = {
    name,
    port: envCfg.sys.port,
    };
  3. 我们再来封装下第三方包,创建lib/openai.js文件

    • openai
    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
    const {Configuration, OpenAIApi} = require("openai");
    const tunnel = require('tunnel');
    const axios = require('axios');
    module.exports = (opts = {}) => {
    const configuration = new Configuration({
    apiKey: opts.key,
    });
    let defaultOpts = {
    timeout: 60000,
    maxContentLength: 20 * 1024 * 1024,
    maxBodyLength: 20 * 1024 * 1024,
    withCredentials: true,
    };
    let isProduction = process.env.NODE_ENV === 'production';
    if (!isProduction) {
    // 本地开发,此处我配置的是我本地的代理地址和端口,需要改成你的。如果是clash的话默认都是7890
    defaultOpts = {
    ...defaultOpts,
    httpsAgent: tunnel.httpsOverHttp({
    proxy: {
    host: '127.0.0.1',
    port: 7890,
    }
    })
    }
    }
    const client = axios.create(defaultOpts);
    const openai = new OpenAIApi(configuration, opts.host, client);
    return async (ctx, next) => {
    // 这样一来,我们就可以使用ctx.openai.xxx调用openai对应的api了
    ctx.openai = openai;
    await next();
    };
    };
  4. 创建routes/index.js,我们再开发两个路由,一个用于聊天,一个用于图片功能。同时别忘了一个根路由,部署完项目后,vercel默认会发送get请求到根路径,来判断项目是否部署成功

    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
    import Router from 'koa-router';

    const router = Router();

    /*聊天*/
    router.post('/completions', async (ctx) => {
    const params = {...ctx.request.body};
    console.debug(__filename, '[createChatCompletion] Request params:', params);
    try {
    const response = await ctx.openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: [{
    role: "user",
    content: params.content
    }],
    });
    if (response.status !== 200) {
    console.error(response.statusText)
    return ctx.body = response.statusText
    }
    ctx.body = response.data.choices[0].message
    } catch (e) {
    console.error(e.stack);
    ctx.body = e.stack
    }
    });

    /*图片*/
    router.post('/imagesGenerations', async (ctx) => {
    const params = {...ctx.request.body};
    console.debug(__filename, '[imageGenerations] Request params:', params);
    // 字数太多ai联想也慢,所以我们这里限制下
    if (params.prompt.length >= 10) {
    return ctx.body = "图片描述超过限制"
    }
    try {
    // 支持的图片尺寸可以查看下官方文档,最小则为256x256
    const response = await ctx.openai.createImage({
    prompt: params.prompt,
    n: 1,
    size: "256x256",
    });
    if (response.status !== 200) {
    console.error(response.statusText)
    return ctx.body = response.statusText
    }
    ctx.body = response.data.data[0].url
    } catch (e) {
    console.error(e.stack);
    ctx.body = e.stack
    }
    });

    /*根路径*/
    router.get('/',async (ctx) => {
    console.debug('success deploy...');
    ctx.body = "ok"
    });

    export default router;
    1. 根路径下创建index.js文件(必须命名index.js,测试其他名字在部署vercel时候不生效),作为启动类。内容如下
    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
    import Koa from 'koa';
    import koaBody from 'koa-body';
    import * as routes from './routes';
    import {sysCfg, envCfg} from "./config";
    import openai from "./lib/openai";
    import cors from '@koa/cors';

    const app = new Koa();

    // ctx.openai
    app.use(openai({...envCfg.chatGpt}))
    // cors
    app.use(cors({
    origin: '*',
    credentials: true
    }))

    // body parser
    app.use(koaBody({
    multipart: true,
    formidable: {
    maxFileSize: 20 * 1024 * 1024,
    },
    }));

    // routes
    Object.keys(routes).forEach((k) => {
    app.use(routes[k].routes())
    .use(routes[k].allowedMethods());
    });

    // error handler
    app.on('error', async (err, ctx) => {
    ctx.status = 500;
    console.error('×××××× System error:', err.stack);
    });

    // listening
    const port = Number(sysCfg.port);
    app.listen(port, '0.0.0.0')
    .on('listening', () => {
    console.log(`Listening on port: ${port}`);
    });
  5. 启动测试前,我们修改下package.jsonscripts,方便后续使用(build指令必须有,部署vercel时候强制要求)

1
2
3
4
5
"scripts": {
"start": "node bin/run",
"build": "node bin/run",
"eslint": "./node_modules/.bin/eslint ./"
},
  1. 启动,执行npm run start,看到如下输出说明成功了
1
2
3
4
5
6
7
8
9
> npm run start        

> chatgpt-project@1.0.0 start
> node bin/run

环境: undefined
加载本地开发环境...
chatgpt-project 服务配置参数加载成功
Listening on port: 4000
  1. 发送请求测试下,看到如下结果说明成功

客户端开发

创建项目

  1. 打开aardio程序,选择web界面,并选择htmx项目

代码开发

  1. 我们双击main文件,看到灰色的界面之后双击即可打开代码界面
  2. 客户端主要用到的ui框架为ChatUI,阿里开发的。官网地址:https://chatui.io/docs/quick-start
  3. 这里我们做了些逻辑,当发送内容以图片:开头,则说明请求图片接口,否则是聊天接口。然后利用jQuery+Ajax发送请求(希望大家没忘了jQuery这个老伙计是咋用的)
  4. 具体代码如下,可以直接将其粘贴至main文件中。关于详细的讲解见注释部分
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import win.ui;
/*DSG{{*/
var winform = win.form(text="ChatGPT 桌面助理";right=757;bottom=467)
winform.add()
/*}}*/

import web.view;
var wb = web.view(winform);

wb.html = /**
<!DOCTYPE html><html>
<head>
<meta charset="utf-8" />
<title>WebView2</title>
<!--加入一些cdn资源文件,主要包括jQuery、react、ChatUI-->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
<script src="https://lib.baomitu.com/react/17.0.2/umd/react.development.js"></script>
<script src="https://lib.baomitu.com/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://lib.baomitu.com/chatui-core/2.4.2/index.min.js"></script>
<link rel="stylesheet" href="https://lib.baomitu.com/chatui-core/2.4.2/index.min.css">
<script src="https://lib.baomitu.com/babel-standalone/7.18.13/babel.min.js"></script>
<style type="text/css">html,body,#app{height:100%}</style>
</head>
<body>

<script type="text/babel">
const { useState,useEffect,useCallback,useRef } = React;
const { default: Chat, Bubble, useMessages, Image } = ChatUI
// 配置服务端地址
const schema = 'http://'
const basePath = 'localhost:4000';
const baseUrl = schema + basePath;
const App = () => {
const { messages, appendMsg, setTyping, updateMsg } = useMessages([{
type: 'text',
content: { text: '主人好,我是 ChatGPT 智能助理,你的贴心小助手~' },
user: { avatar: 'https://gitee.com/xiaowu_wang/pic/raw/master/1680245769332.jpg' },
}]);

function handleSend(type, val) {
if (type === 'text' && val.trim()) {
// 自己发送的文本内容,放在右侧
appendMsg({
type: 'text',
content: { text: val },
position: 'right',
});

setTyping(true);
// 判断是否以图片二字开头,然后以:进行分割取后半部分
if (val.startsWith("图片")) {
val = val.substr(3)
// 定义url
let url = baseUrl + '/imagesGenerations'
$.ajax({
url: url,
type: 'POST',
data: {
"prompt": val
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
appendMsg({
type: 'image',
content: { picUrl: response },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}else {
// 定义url
let url = baseUrl + '/completions'
$.ajax({
url: url,
type: 'POST',
data: {
"content": val
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
appendMsg({
type: 'text',
content: { text: response.content },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}

}
}

function renderMessageContent(msg) {
const { type, content } = msg;

// 根据消息类型来渲染
switch (type) {
case 'text':
return <Bubble content={content.text} />;
case 'image':
return (
<Bubble type="image">
<img src={content.picUrl} alt="" />
</Bubble>
);
default:
return null;
}
}

return (
<Chat
navbar={{ title: '' }}
messages={messages}
renderMessageContent={renderMessageContent}
onSend={handleSend}
/>
);
};

ReactDOM.render(<App />, document.querySelector('#app'));
</script>
<div id="app"></div>
**/

winform.show();
win.loopMessage();
  1. 点击运行,启动测试(这期间可以按F12打开开发者工具进行调试)

  2. 看到如下界面说明成功

Prompt问题优化,联系上下文

问题复现

这里我们复现个问题

我们明明刚跟它说完李白,程序就给"忘了"。

这是因为程序并没将上下文联系起来,如果我们将对话变成这样

程序就可以正确的将上下文联系起来了

代码调整

所以我们这里将客户端代码调整下(看注释)

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import win.ui;
/*DSG{{*/
var winform = win.form(text="ChatGPT 桌面助理";right=757;bottom=467)
winform.add()
/*}}*/

import web.view;
var wb = web.view(winform);

wb.html = /**
<!DOCTYPE html><html>
<head>
<meta charset="utf-8" />
<title>WebView2</title>
<!--加入一些cdn资源文件,主要包括jQuery、react、ChatUI-->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
<script src="https://lib.baomitu.com/react/17.0.2/umd/react.development.js"></script>
<script src="https://lib.baomitu.com/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://lib.baomitu.com/chatui-core/2.4.2/index.min.js"></script>
<link rel="stylesheet" href="https://lib.baomitu.com/chatui-core/2.4.2/index.min.css">
<script src="https://lib.baomitu.com/babel-standalone/7.18.13/babel.min.js"></script>
<style type="text/css">html,body,#app{height:100%}</style>
</head>
<body>

<script type="text/babel">
const { useState,useEffect,useCallback,useRef } = React;
const { default: Chat, Bubble, useMessages, Image } = ChatUI
// 配置服务端地址
const schema = 'http://'
const basePath = 'localhost:4000';
const baseUrl = schema + basePath;
let sessionMsg = "";
const App = () => {
const { messages, appendMsg, setTyping, updateMsg } = useMessages([{
type: 'text',
content: { text: '主人好,我是 ChatGPT 智能助理,你的贴心小助手~' },
user: { avatar: 'https://gitee.com/xiaowu_wang/pic/raw/master/1680245769332.jpg' },
}]);

function handleSend(type, val) {
if (type === 'text' && val.trim()) {
// 自己发送的文本内容,放在右侧
appendMsg({
type: 'text',
content: { text: val },
position: 'right',
});

setTyping(true);
// 判断是否以图片二字开头,然后以:进行分割取后半部分
if (val.startsWith("图片")) {
val = val.substr(3)
// 定义url
let url = baseUrl + '/imagesGenerations'
$.ajax({
url: url,
type: 'POST',
data: {
"prompt": val
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
appendMsg({
type: 'image',
content: { picUrl: response },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}else {
// 处理sessionMsg,保证上下文;
sessionMsg += "你: " + val + "\nAI:";
// 定义url
let url = baseUrl + '/completions'
$.ajax({
url: url,
type: 'POST',
data: {
"content": sessionMsg
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
// 处理sessionMsg,保证上下文
sessionMsg += (response.content + "\n");
appendMsg({
type: 'text',
content: { text: response.content },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}

}
}

function renderMessageContent(msg) {
const { type, content } = msg;

// 根据消息类型来渲染
switch (type) {
case 'text':
return <Bubble content={content.text} />;
case 'image':
return (
<Bubble type="image">
<img src={content.picUrl} alt="" />
</Bubble>
);
default:
return null;
}
}

return (
<Chat
navbar={{ title: '' }}
messages={messages}
renderMessageContent={renderMessageContent}
onSend={handleSend}
/>
);
};

ReactDOM.render(<App />, document.querySelector('#app'));
</script>
<div id="app"></div>
**/

winform.show();
win.loopMessage();

启动测试

这样一来就符合我们想要的结果了

项目上线

将项目部署到vercel

  1. 安装vercel-cli,执行npm i vercel -g,并使用vercel --version查看安装是否成功(如果是windows,这里最好不要使用power shell,可能会不识别vercel命令)

  2. 切回到服务端的根目录

  3. 先创建个vercel.json文件,用于vercel部署引导,内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    "version": 2,
    "builds": [
    {
    "src": "index.js",
    "use": "@vercel/node"
    }
    ],
    "routes": [
    {
    "src": "/(.*)",
    "dest": "/index.js"
    }
    ]
    }
  4. 执行vercel login,会指引你到浏览器登录vercel

  5. 接下来我们就需要正式部署了。我们在项目中使用环境变量来进行配置替换,所以这里我们执行vercel --prod -e SYS_PORT=4000 -e CHAT_GPT_HOST=https://api.openai.com/v1 -e OPEN_API_KEY={这里替换成你的key}

    具体内容如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    $ vercel --prod -e SYS_PORT=4000 -e CHAT_GPT_HOST=https://api.openai.com/v1 -e OPEN_API_KEY=sk-xxx

    Vercel CLI 28.18.3
    ? Set up and deploy “D:\Project\behappy-project\chatGpt-project”? [Y/n] y
    ? Which scope do you want to deploy to? wang-xiaowu
    ? Link to existing project? [y/N] n
    ? What’s your project’s name? chat-gpt-project
    ? In which directory is your code located? ./
    🔗 Linked to wang-xiaowu/chat-gpt-project (created .vercel)
    🔍 Inspect: https://vercel.com/wang-xiaowu/chat-gpt-project/9dRaCg4ooTRJFphKBJAFxCA2bdRc [3s]
    ✅ Production: https://chat-gpt-project-sandy.vercel.app [36s]
    ❗️ Due to `builds` existing in your configuration file, the Build and Development Settings defined in your Project Settings will not apply. Learn More: https://vercel.link/unused-build-settings
  6. 查看你的vercel -> dashboard,如下则成功

客户端测试

  1. 接下来我们改下客户端地址
  1. 测试效果

客户端发布

  1. 点击发布
  1. 更新排除目录
  1. 打开发布目录即可找到你发布的exe程序了

配置DNS解析,解决国内无法访问的情况

问题

在国内的话,这个域名只有在我们开了代理的情况下可以访问,所以这里我们需要购买域名并配置DNS解析,去"解除限制"

购买域名

  1. 购买域名的平台有很多,国外有google,Cloudflare。国内有阿里云,腾讯云,西部数据等。下面我们以西部数据举例

  2. 登录网站:https://www.west.cn/

  3. 点击登录,最好以微信登录,方便。首次注册需要你实名认证些东西,按指引做即可

  4. 点击域名注册

  5. 目前发现.icu是最便宜的,一年7元,我买了.site。之后会进入检验当前域名是否已被注册的界面,如果没有那么恭喜你,可以点击立即注册按钮进行购买了

DNS解析

  1. 购买成功后,进入管理中心 -> 域名管理,找到你刚刚购买的域名,点击解析
  1. 将其配置成如下方式
主机名类型对应值
@A76.76.21.21
wwwCNAMEcname-china.vercel-dns.com

vercel配置Domains

回到你的vercel dashboard,按下图将你购买的域名配置进去

修改客户端代码,再次测试

  1. 修改连接地址为你的域名

  2. 效果

Stream流式应答,实现打字机效果

现象

我们平时使用ChatGPT的时候会发现它的回答实际是以类似打字机的方式回应的,我们也来做下调整。

服务端代码调整

  1. 在createChatCompletion的api中,我们将参数stream设置为true如:stream: true,即可以实现streaming输出。该api还提供了第二个options参数,用来配置axios,这里将第二个参数配置为{responseType: 'stream'}

  2. 其次,既然传输方式改了,我们就需要考虑下技术选型。以前我们使用的是客户端利用ajax一次性将服务端的数据"拿过来",而现在我们需要改变成服务器向客户端推送数据。关于推送数据,常用的方式除了 WebSocket,还有Server-Sent Events,这两篇文章都出自阮一峰老师的博客,可以看下。

    而我们将会采用websocket。

    为什么不用sse?一:sse只支持单向传输,也就是说客户端还是要以其他方式向服务端请求数据。二:EventSource仅支持监听GET

  3. 安装koa-websocket,服务端websocket实现:npm i -S koa-websocket@7.0.0

  4. 添加文件lib/chat.js,内容如下

    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
    module.exports = () => {
    return async (ctx, next) => {
    try {
    ctx.websocket.on('message', async function (message) {
    const params = message.toString()
    if (!params || params.length === 0) {
    return
    }
    console.debug(__filename, '[createChatCompletion] Request params:', params);

    const streamResponse = await ctx.openai.createChatCompletion({
    model: "gpt-3.5-turbo",
    stream: true,
    messages: [{
    role: "user",
    content: params
    }],
    }, {responseType: 'stream'});

    if (streamResponse.status !== 200) {
    console.error(streamResponse.statusText)
    return ctx.websocket.send(streamResponse.statusText);
    }
    streamResponse.data.on('data', chunk => {
    const lines = chunk
    .toString()
    .split('\n')
    .filter((line) => line.trim().startsWith('data: '))
    for (const line of lines) {
    const message = line.replace(/^data: /, '')
    if (message === '[DONE]') {
    // 客户端判断输出内容是否是`[DONE]`
    console.debug('内容结束...')
    return ctx.websocket.send('[DONE]')
    }

    const json = JSON.parse(message)
    const token = json.choices[0].delta.content
    if (token) {
    console.debug('发送...', token)
    ctx.websocket.send(token)
    }
    }
    })
    });

    } catch (e) {
    console.error(e.message)
    ctx.websocket.send(e.message)
    } finally {
    await next();
    }
    };
    };
  5. 修改index.js,添加以下代码,使koa和websocket共享一个端口

1
2
3
4
5
6
7
8
9
10
11
......
import websockify from 'koa-websocket';
import chat from "./lib/chat";

// const app = new Koa();
const app = websockify(new Koa());
// ctx.openai
app.ws.use(openai({...envCfg.chatGpt}))
// websocket event
app.ws.use(chat())
......

客户端代码调整

这里参考了几个链接

  1. ChatUI提供了updateMsg函数,可以使用updateMsg监听result来实现打字机效果
  2. 使用 useState 需要注意的 5 个问题。最开始想用ChatUI提供的useState来存些状态数据,然后测试发现他这个东西本身也是个promise,不是实时的...

贴下完整改造后代码(看注释)

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import win.ui;
/*DSG{{*/
var winform = win.form(text="ChatGPT 桌面助理";right=757;bottom=467)
winform.add()
/*}}*/

import web.view;
var wb = web.view(winform);

wb.html = /**
<!DOCTYPE html><html>
<head>
<meta charset="utf-8" />
<title>WebView2</title>
<!--加入一些cdn资源文件,主要包括jQuery、react、ChatUI-->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
<script src="https://lib.baomitu.com/react/17.0.2/umd/react.development.js"></script>
<script src="https://lib.baomitu.com/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://lib.baomitu.com/chatui-core/2.4.2/index.min.js"></script>
<link rel="stylesheet" href="https://lib.baomitu.com/chatui-core/2.4.2/index.min.css">
<script src="https://lib.baomitu.com/babel-standalone/7.18.13/babel.min.js"></script>
<style type="text/css">html,body,#app{height:100%}</style>
</head>
<body>

<script type="text/babel">
const { useState,useEffect,useCallback,useRef } = React;
const { default: Chat, Bubble, useMessages, Image } = ChatUI
// 配置服务端地址
const schema = 'http://'
const basePath = 'localhost:4000';
const baseUrl = schema + basePath;
let sessionMsg = "";
const App = () => {
const [msgID, setMsgID] = useState("");
const [result, setResult] = useState("");
let msg = "";
const { messages, appendMsg, setTyping, updateMsg } = useMessages([{
type: 'text',
content: { text: '主人好,我是 ChatGPT 智能助理,你的贴心小助手~' },
user: { avatar: 'https://gitee.com/xiaowu_wang/pic/raw/master/1680245769332.jpg' },
}]);

useEffect(() => {
// 监听result的改变,来实现打字机效果
updateMsg(msgID, {
type: "text",
content: { text: result }
});
}, [result]);

const socket = new WebSocket('ws://' + basePath);
// 监听连接打开事件
socket.addEventListener('open', (event) => {
console.log('WebSocket 连接已打开');
});
// 监听收到消息事件
socket.addEventListener('message', (event) => {
console.log('收到服务器消息:', event.data);
// 处理sessionMsg,保证上下文
if(event.data === '[DONE]'){
// 说明是结尾
sessionMsg += '\n';
}else {
msg += event.data;
setResult(msg);
sessionMsg += event.data;
}

});
// 监听连接关闭事件
socket.addEventListener('close', (event) => {
console.log('WebSocket 连接已关闭');
});
// 获取当前时间戳
function nanoid() {
return new Date().getTime().toString();
}
function handleSend(type, val) {
// 先设置一个唯一的msgID
const msgID = nanoid();
setMsgID(msgID);
// 重置msg
msg = "";
// 重置对话框
setResult(msg);
if (type === 'text' && val.trim()) {
// 自己发送的文本内容,放在右侧
appendMsg({
type: 'text',
content: { text: val },
position: 'right',
});

setTyping(true);
// 判断是否以图片二字开头,然后以:进行分割取后半部分
if (val.startsWith("图片")) {
val = val.substr(3)
// 定义url
let url = baseUrl + '/imagesGenerations'
$.ajax({
url: url,
type: 'POST',
data: {
"prompt": val
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
appendMsg({
type: 'image',
content: { picUrl: response },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}else {
// 处理sessionMsg,保证上下文;
sessionMsg += "你: " + val + "\nAI:";
appendMsg({
_id: msgID,
type: 'text',
content: { text: result },
})
socket.send(sessionMsg);

// 定义url
//let url = baseUrl + '/completions'
//$.ajax({
// url: url,
// type: 'POST',
// data: {
// "content": sessionMsg
// },
// success: function(response) {
// console.log("请求成功,返回数据为:" + response);
// // 处理sessionMsg,保证上下文
// sessionMsg += (response.content + "\n");
// appendMsg({
// type: 'text',
// content: { text: response.content },
// })
// },
// error: function(xhr, status, error) {
// console.log("请求失败,错误信息为:" + error);
// }
//});
}

}
}

function renderMessageContent(msg) {
const { type, content } = msg;

// 根据消息类型来渲染
switch (type) {
case 'text':
return <Bubble content={content.text} />;
case 'image':
return (
<Bubble type="image">
<img src={content.picUrl} alt="" />
</Bubble>
);
default:
return null;
}
}

return (
<Chat
navbar={{ title: '' }}
messages={messages}
renderMessageContent={renderMessageContent}
onSend={handleSend}
/>
);
};

ReactDOM.render(<App />, document.querySelector('#app'));
</script>
<div id="app"></div>
**/

winform.show();
win.loopMessage();

启动测试

  1. 因为vercel目前还不支持websocket,所以我们只能在本地测试这一块的改造
  1. 启动测试后,效果图和文章开头的一样

语音功能

参考官网:https://platform.openai.com/docs/api-reference/audio

官方提供了两个接口

将音频转录为输入语言。

我们主要对接这个接口,该接口提供了几个参数

file:必选,要转录的音频文件,格式为以下格式之一:mp3、mp4、mpeg、mpga、m4a、wav或webm

model:必选,要使用的模型的ID。目前只有whisper-1可用。

prompt:一个可选的文本来指导模型的风格或继续以前的音频片段。提示应与音频语言匹配。

response_format:转录输出的格式,使用以下选项之一:json、text、srt、verbose_json或vtt。默认json

temperature:介于0和1之间。较高的值(如0.8)将使输出更随机,而较低的值(如0.2)将使其更集中和确定性。如果设置为0,模型将使用对数概率自动增加temperature,直到达到某些阈值。

language:输入音频的语言。以ISO-639-1格式提供输入语言将改善准确性和延迟。

将音频翻译成英语。

实现效果

pdf格式无法展示动图:可点击该地址进行查看

问题

aardio生成桌面端控制录音比较麻烦,所以下面的操作我们将会把客户端集成到h5

代码开发

依赖安装

1
2
// 页面渲染
npm i -S koa-views@^8.0.0

修改index.js,添加如下代码

1
2
3
4
5
6
...
import koaViews from 'koa-views';
...
// 保证放在routes的上面即可
app.use(koaViews(path.join(__dirname, 'static/'), {extension: 'html'}));
...

修改routes/index.js中根路径代码

1
2
3
4
5
6
7
...
/*根路径,当访问根路径时,渲染static/index.html页面*/
router.get('/',async (ctx) => {
console.debug('success deploy...');
await ctx.render('index');
});
...

添加router,/audio/transcriptions

需要注意的是,之后我们需要将音频文件暂存磁盘,这里我是windows,配置在D://,按需自行修改

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
router.post('/audio/transcriptions', async (ctx) => {
const params = ctx.request.body;
console.debug(__filename, '[audioTranscriptions] Request params:', params);
try {
const base64String = params.msg;
const {language} = params;
const buffer = Buffer.from(base64String, 'base64');
const fileName = `D://${Date.now()}.mp3`;
// 将Buffer对象写入到mp3文件中
await fs.writeFileSync(fileName, buffer);
const response = await ctx.openai.createTranscription(
fs.createReadStream(fileName),
'whisper-1',
'',
'json',
0,
language,
);
if (response.status !== 200) {
console.error(response.statusText);
return ctx.body = response.statusText;
}
// 响应内容
const {text} = response.data;
console.debug('audio响应信息:', text);
ctx.body = text;
} catch (e) {
console.error(e.stack);
ctx.body = e.stack;
}
});

添加/static/index.html

这里我们仅是将之前aardio中的页面copy过来,并添加录音功能

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
<!DOCTYPE html><html>
<head>
<meta charset="utf-8" />
<title>BeHappy 智能助理</title>

<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
<script src="https://lib.baomitu.com/react/17.0.2/umd/react.development.js"></script>
<script src="https://lib.baomitu.com/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://lib.baomitu.com/chatui-core/2.4.2/index.min.js"></script>
<link rel="stylesheet" href="https://lib.baomitu.com/chatui-core/2.4.2/index.min.css">
<script src="https://lib.baomitu.com/babel-standalone/7.18.13/babel.min.js"></script>
<script src="https://g.alicdn.com/chatui/icons/0.3.0/index.js"></script>

<style type="text/css">html,body,#app{height:100%}</style>
</head>
<body>
<script type="text/babel">
const { useState,useEffect,useCallback,useRef } = React;
const { default: Chat, Bubble, useMessages, Image, Notice, Card, CardMedia, CardTitle, CardText, CardActions, Button, toast, Input } = ChatUI
const schema = 'http://'
const basePath = 'localhost:4000';
const baseUrl = schema + basePath;
let sessionMsg = "";
const App = () => {
const msgRef = useRef(null)
window.appendMsg = appendMsg
window.msgRef = msgRef
let recorder;
let audio;
let stopRecoderFlag = true;
let recordBlob;
const fileReader = new FileReader();
const [msgID, setMsgID] = useState("");
const [result, setResult] = useState("");
// 语音识别语言,我们默认给zh
const [recordLanguage, setRecordLanguage] = useState('zh');
let msg = "";
const { messages, appendMsg, setTyping, updateMsg, prependMsgs } = useMessages([{
type: 'text',
content: { text: '主人好,我是 BeHappy 智能助理,你的贴心小助手~' },
user: { avatar: 'https://gitee.com/xiaowu_wang/pic/raw/master/1680245769332.jpg' },
}]);
const toolbar = [
{
type: 'image',
icon: 'image',
title: '相册',
},
{
type: 'speech',
icon: 'mic',
title: '语音输入'
}
];
// 默认快捷短语,可选
const defaultQuickReplies = [
{
icon: 'message',
name: '聊电影',
isNew: true,
isHighlight: true,
},
{
icon: 'message',
name: '聊动漫',
isNew: true,
isHighlight: true,
}
];
useEffect(() => {
// 监听result的改变,来实现打字机效果
updateMsg(msgID, {
type: "text",
content: { text: result }
});
}, [result]);

const socket = new WebSocket('ws://' + basePath);
// 监听连接打开事件
socket.addEventListener('open', (event) => {
console.log('WebSocket 连接已打开');
});
// 监听收到消息事件
socket.addEventListener('message', (event) => {
console.log('收到服务器消息:', event.data);
// 处理sessionMsg,保证上下文
if(event.data === '[DONE]'){
// 说明是结尾
sessionMsg += '\n';
}else {
msg += event.data;
setResult(msg);
sessionMsg += event.data;
}
});
// 监听连接关闭事件
socket.addEventListener('close', (event) => {
console.log('WebSocket 连接已关闭');
});
// 获取当前时间戳
function nanoid() {
return new Date().getTime().toString();
}
// 快捷短语回调,可根据 item 数据做出不同的操作,这里以发送文本消息为例
function handleQuickReplyClick(item) {
handleSend('text', item.name);
}
function renderBeforeMessageList() {
return <Notice
content='欢迎使用 BeHappy 智能助理'
/>
}
function toolbarClick (item, ctx) {
if(item.type === 'image'){
// todo 图片解析
appendMsg({
type: 'text',
content: { text: '抱歉,该功能暂未实现' }
});
} else if(item.type === 'speech'){
appendMsg({
type: 'speech-type',
content: {}
})
}

}
function handleSend(type, val) {
// 先设置一个唯一的msgID
const msgID = nanoid();
setMsgID(msgID);
// 重置msg
msg = "";
// 重置对话框
setResult(msg);
if (type === 'text' && val.trim()) {
appendMsg({
type: 'text',
content: { text: val },
position: 'right',
});

setTyping(true);
// 判断是否以图片二字开头,然后以:进行分割取后半部分
if (val.startsWith("图片")) {
val = val.substr(3)
// 定义url
const url = baseUrl + '/imagesGenerations'
$.ajax({
url: url,
type: 'POST',
data: {
"prompt": val
},
success: function(response) {
console.log("请求成功,返回数据为:" + response);
appendMsg({
type: 'image',
content: { picUrl: response },
})
},
error: function(xhr, status, error) {
console.log("请求失败,错误信息为:" + error);
}
});
}else {
// 处理sessionMsg,保证上下文;
sessionMsg += "你: " + val + "\nAI:";
appendMsg({
_id: msgID,
type: 'text',
content: { text: result },
})
socket.send(sessionMsg);
}

}
}
function startRecording() {
navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
toast.success('已开始录音...');
recorder = new MediaRecorder(stream);
audio = new Audio();
const chunks = [];

recorder.ondataavailable = e => {
chunks.push(e.data);
};

recorder.onstop = () => {
recordBlob = new Blob(chunks, {type: 'audio/mp3'});
audio.src = URL.createObjectURL(recordBlob);
};
recorder.start();
stopRecoderFlag = false;
})
.catch((error) => {
console.log(error);
});
}

function stopRecording() {
if (!recorder || stopRecoderFlag) {
return toast.show('请先录音!');
}
toast.success('录音结束...');
recorder.stop();
stopRecoderFlag = true;
}

function playRecording() {
if (!recorder) {
return toast.show('请先录音!');
}
if (!stopRecoderFlag) {
return toast.show('请先停止录音!');
}
toast.success('开始播放录音...');
audio.play();
}

function sendRecording() {
if (!recorder) {
return toast.show('请先录音!');
}
if (!stopRecoderFlag) {
return toast.show('请先停止录音!');
}
fileReader.readAsArrayBuffer(recordBlob);
// 释放
URL.revokeObjectURL(recordBlob);
}

fileReader.onload = function () {
toast.success('发送录音中...');
// 获取转换后的ArrayBuffer
const arrayBuffer = this.result;
const uint8Array = new Uint8Array(arrayBuffer);
// 将Uint8Array对象转换为base64编码的字符串
const base64String = btoa(String.fromCharCode.apply(null, uint8Array));
// 定义url
const url = baseUrl + '/audio/transcriptions';
// 发送数据
$.ajax({
url: url,
type: 'POST',
data: {
'msg': base64String,
'language': recordLanguage
},
success: function (response) {
console.log('请求成功,返回数据为:' + response);
handleSend('text', response);
},
error: function (xhr, status, error) {
console.log('请求失败,错误信息为:' + error);
}
});
};

function renderMessageContent(msg) {
const { type, content } = msg;

// 根据消息类型来渲染
switch (type) {
case 'text':
return <Bubble content={content.text} />;
case 'image':
return (
<Bubble type="image">
<img src={content.picUrl} alt="" />
</Bubble>
);
case 'speech-type':
return (
<div className="demo-btn">
<h4>录音</h4>
<div>
<Input value={recordLanguage} onChange={val => setRecordLanguage(val)} autoSize
placeholder="录音语言,具体值参考: List_of_ISO_639-1_codes"/>
</div>
<Button id="recordButton" onClick={startRecording}>开始录音</Button>
<Button id="stopButton" onClick={stopRecording}>停止录音</Button>
<Button id="playButton" onClick={playRecording}>播放录音</Button>
<Button id="sendButton" color="primary" onClick={sendRecording}>发送录音</Button>
</div>
);
default:
return null;
}
}

return (
<Chat
renderBeforeMessageList={renderBeforeMessageList}
navbar={{
leftContent: {
icon: 'chevron-left',
title: 'Back'
},
rightContent: [
{
icon: 'apps',
title: '暂未实现'
},
{
icon: 'ellipsis-h',
title: '暂未实现'
}
],
title: 'BeHappy 智能助理'
}}
messages={messages}
renderMessageContent={renderMessageContent}
quickReplies={defaultQuickReplies}
onQuickReplyClick={handleQuickReplyClick}
onSend={handleSend}
placeholder={'请输入...'}
toolbar={toolbar}
onToolbarClick={toolbarClick}
messagesRef={msgRef}
recorder={{
canRecord: true,
onStart: () => {
console.log('录音')
}
}}
wideBreakpoint="600px"
onImageSend={() => Promise.resolve()}
/>
);
};

ReactDOM.render(<App />, document.querySelector('#app'));
</script>
<div id="app"></div>

启动项目测试

浏览器输入http://localhost:4000/

Fine-tune与Embedding

内容介绍摘自:https://zhuanlan.zhihu.com/p/609359047

OpenAI 提供了两项基于 GPT-3 模型的能力:

  1. fine-tune 微调
  2. embedding 嵌入

fine-tune

一般称之为微调。

模型底层更通用,顶层更垂直,fine-tune 的原理是在不改动(或不能改动)预训练模型的基础上,在模型「顶层」增加分类器或一些特征映射,使微调后的模型能够更贴合实际使用场景。

在 OpenAI 中,可以在不改动 GPT-3 大模型的情况下,针对 prompt 和 completion 的进行训练,对「句式」、「情感」等特征进行优化。

OpenAI-fine-tune
GPT-3 已经预训练了大量的互联网内容。只需要在 prompt 里写少量的用例,他基本可以感知你的用意,并生成一段基本合理的 completion。这个功能一般叫做 "few-shot learning".
fine-tune 基于 few-shot learning,通过训练比写在 prompt 里更多的示例,可以得到更好的结果。模型被微调后,就不用在 prompt 里再写一些实例了。这样可以节省成本和耗时。

使用场景:

  1. 想让 GPT-3 按照某种格式来识别 Prompt ,或按照某种格式来回答
  2. 想让 GPT-3 按照某种语气、性格来回答
  3. 想让 completion 具有某种倾向

比如不进行 fine-tune 提问

1
2
3
4
5
prompt: 
小红喜欢洋娃娃。小红的性别是?

completion:
女孩,所以她很喜欢洋娃娃。洋娃娃通常有着丰富多彩的服装和装饰,可以让小红玩得很开心。此外,红还可以给洋娃娃起名字,造出一个属于自己的小世界,从而获得更多的乐趣。

GPT-3 详尽的回答了你的问题,但是可能你只想知道是 男 or 女。

在 prompt 里加入示例

1
2
3
4
5
6
7
8
prompt:
示例:小红喜欢洋娃娃。小红的性别是?
答案:女

下面是问题:小明喜欢坦克,小明的性别是?

completion:
答案:男

经过提示的 prompt 会按照示例的格式回答你的问题。

如果通过对大量如下数据进行 fine-tuning。

1
2
3
4
5
prompt:
小红喜欢洋娃娃。小红的性别是?

completion:
答案: 女

训练后的模型中,按照 prompt 格式书写,那么 completion 会自动按照期望的格式返回,而不是返回其他内容.

类似于 Masked Language Modeling(MLM) ,系统会将回答识别为「答案: [mask] 」,模型去预测 mask 的内容,或者理解为「完形填空」

embedding

一般称之为嵌入。

embedding 一般是指将一个内容实体映射为低维向量,从而可以获得内容之间的相似度。

OpenAI 的 embedding 是计算文本与维度的相关性,默认的 ada-002 模型会将文本解析为 1536 个维度。用户可以通过文本之间的 embedding 计算相似度。

embedding 的使用场景是可以根据用户提供的语料片段与 prompt 内容计算相关度,然后将最相关的语料片段作为上下文放到 prompt 中,以提高 completion 的准确率。

具体可以看 (二)如何使用 Embedding 提升回答质量?

使用场景:

  1. 获取文本特征向量
  2. 提供「相关」上下文,让 GPT-3 依据上下文回答

fine-tune 和 embedding 可以结合使用,比如通过 fine-tune 训练基于 context 识别 prompt 模型,再使用此模型使用 embedding 插入上下文,这样新模型也能更好地理解 prompt

两者如何选择

我有一堆语料,想让 GPT-3 依据我的语料输出内容 - 使用 embedding

想让 GPT-3 模仿一个温柔贤惠的女人和我对话 - 使用 fine-tune

希望用户按照一定格式提交问题 - 使用 fine-tune

可以根据产品的使用手册来回答用户的问题 - 使用 embedding

数据训练步骤

安装openai(python环境3以上)

1
pip install --upgrade openai

绑定key

1
2
export OPENAI_API_KEY="<OPENAI_API_KEY>"
set OPENAI_API_KEY="<OPENAI_API_KEY>" (windows适用)

准备数据

分类

在分类问题中,提示中的每个输入都应对应到预定义的类别

判断真假:判断真实性

假设你希望确保你网站上的广告文字提及正确的产品和公司。换句话说,你要确保模型没有胡编乱造。你可能想要微调过滤掉不正确广告的分类器。

数据集可能类似于以下内容:

1
2
{"prompt":"Company: BHFF insurance\nProduct: allround insurance\nAd:One stop shop for all your insurance needs!\nSupported:", "completion":" yes"}
{"prompt":"Company: Loft conversion specialists\nProduct: -\nAd:Straight teeth in weeks!\nSupported:", "completion":" no"}

在上面的示例中,我们使用了包含公司名称、产品和相关广告的结构化输入。作为分隔符,我们使用\nSupported:它清楚地将提示与完成分开。对于足够数量的示例,分隔符不会产生太大影响(通常小于 0.4%),只要它没有出现在提示或完成中即可。

对于这个用例,我们微调了一个 ada 模型,因为它会更快、更便宜,而且性能将与更大的模型相当,因为它是一个分类任务

判断好坏:判断情绪

假设你想要了解特定文字的正面或负面程度。数据集可能类似于以下内容:

1
2
{"prompt":"Overjoyed with the new iPhone! ->", "completion":" positive"}
{"prompt":"@lakers disappoint for a third straight night https://t.co/38EFe43 ->", "completion":" negative"}

对模型进行微调后,你可以通过在logprobs=2完成请求上设置来取回第一个完成令牌的对数概率。正类别的概率越高,相对情绪就越高。

区分类别:邮件分类

假设你希望将收到的电子邮件归入大量预定义类别之一。对于大量类别的分类,我们建议你将这些类别转换为数字,最多可处理约 500 个类别。我们观察到,由于标记化,在数字前添加一个空格有时会对性能略有帮助。你可能希望按如下方式构建训练数据:

1
{"prompt":"Subject: Update my address\nFrom:Joe Doe\nTo:support@ourcompany.com\nDate:2021-06-03\nContent:Hi,\nI would like to update my billing address to match my delivery address.\n\nPlease let me know once done.\n\nThanks,\nJoe\n\n###\n\n", "completion":" 4"}

在上面的示例中,我们使用了一封上限为 2043 个令牌的传入电子邮件作为输入。(这允许使用 4 个标记分隔符和一个标记完成,总计为 2048。)作为分隔符,我们使用并删除了电子邮件中\n\n###\n\n出现的所有内容。###

条件生成

条件生成是需要在给定某种输入的情况下生成内容的问题。这包括释义、总 结、实体提取、编写给定规范的产品描述、聊天机器人等。

生成:广告

这是一个生成用例,因此你需要确保提供的样本具有最高质量,因为微调模 型将尝试模仿给定示例的风格(和错误)。一个好的起点是大约 500 个示例。示 例数据集可能如下所示:

1
{"prompt":"Samsung Galaxy Feel\nThe Samsung Galaxy Feel is an Android smartphone developed by Samsung Electronics exclusively for the Japanese market. The phone was released in June 2017 and was sold by NTT Docomo. It runs on Android 7.0 (Nougat), has a 4.7 inch display, and a 3000 mAh battery.\nSoftware\nSamsung Galaxy Feel runs on Android 7.0 (Nougat), but can be later updated to Android 8.0 (Oreo).\nHardware\nSamsung Galaxy Feel has a 4.7 inch Super AMOLED HD display, 16 MP back facing and 5 MP front facing cameras. It has a 3000 mAh battery, a 1.6 GHz Octa-Core ARM Cortex-A53 CPU, and an ARM Mali-T830 MP1 700 MHz GPU. It comes with 32GB of internal storage, expandable to 256GB via microSD. Aside from its software and hardware specifications, Samsung also introduced a unique a hole in the phone's shell to accommodate the Japanese perceived penchant for personalizing their mobile phones. The Galaxy Feel's battery was also touted as a major selling point since the market favors handsets with longer battery life. The device is also waterproof and supports 1seg digital broadcasts using an antenna that is sold separately.\n\n###\n\n", "completion":"Looking for a smartphone that can do it all? Look no further than Samsung Galaxy Feel! With a slim and sleek design, our latest smartphone features high-quality picture and video capabilities, as well as an award winning battery life. END"}

这里我们使用了多行分隔符,因为维基百科文章包含多个段落和标题。我们还使用了一个简单的结束标记,以确保模型知道何时应该完成完成

生成:提炼总结

这类似于语言转换任务。为了提高性能,最好按字母顺序或按照它们在原始文本中出现的相同顺序对不同的提取实体进行排序。这将有助于模型跟踪需要按顺序生成的所有实体。数据集可能如下所示:

1
{"prompt":"Portugal will be removed from the UK's green travel list from Tuesday, amid rising coronavirus cases and concern over a "Nepal mutation of the so-called Indian variant". It will join the amber list, meaning holidaymakers should not visit and returnees must isolate for 10 days...\n\n###\n\n", "completion":" Portugal\nUK\nNepal mutation\nIndian variant END"}

多行分隔符效果最好,因为文本可能包含多行。理想情况下,输入提示的类 型会高度多样化(新闻文章、维基百科页面、推文、法律文件),这反映了提取 实体时可能遇到的文本。

生成:聊天机器人

聊天机器人通常会包含有关对话的相关上下文(订单详细信息)、到目前为止的对话摘要以及最近的消息。对于这个用例,相同的过去对话可以在数据集中生成多行,每次都有稍微不同的上下文,对于每个代理生成作为完成。这个用例将需要几千个示例,因为它可能会处理不同类型的请求和客户问题。为确保高质量的性能,我们建议审查对话样本以确保代理消息的质量。可以使用单独的文本转换微调模型生成摘要。数据集可能如下所示:

1
2
{"prompt":"Summary: <summary of the interaction so far>\n\nSpecific information:<for example order details in natural language>\n\n###\n\nCustomer: <message1>\nAgent: <response1>\nCustomer: <message2>\nAgent:", "completion":" <response2>\n"}
{"prompt":"Summary: <summary of the interaction so far>\n\nSpecific information:<for example order details in natural language>\n\n###\n\nCustomer: <message1>\nAgent: <response1>\nCustomer: <message2>\nAgent: <response2>\nCustomer: <message3>\nAgent:", "completion":" <response3>\n"}
数据验证

通过官方的数据验证工具,会验证传入的数据的正确性,同时会生成符合要求的jsonl文件。(支持上传CSV、TSV、XLSX、JSON或JSONL文件)

1
2
openai tools fine_tunes.prepare_data -f <LOCAL_FILE>
openai --api-key <OPENAI_API_KEY> tools fine_tunes.prepare_data -f <数据文件> (windows适用)
训练

准备好数据集之后,通过命令行开始训练

基本模型分类:

ada:最快、价格最低的模型,适用于简单的分析文本,简单分类,文本修正,关键词搜索等

babbage:比ada价格、性能高一点的模型,适用于一般类型的分类识别、语义分析等

curie :性能、功能、价格高于babbage,适用于语言翻译、复杂的分类,情感、总结等

davinci:功能、性能、价格最高的模型,适用于表达复杂的意图、因果关系分析、创意生成、语义搜索、摘要总结等

1
2
openai api fine_tunes.create -t <数据文件> -m <基础模型>
openai --api-key <OPENAI_API_KEY> api fine_tunes.create -t <数据文件> -m <基础模型> (windows使用本命令)

训练会根据数据量大小、选择的模型,花费不同的时长(可以通过查询训练模型查看训练的状态),训练完成后,通过查询,获得训练完成后的模型名称

1
2
3
4
5
6
7
8
9
10
11
# 查询全部训练模型清单(windows需要加 --api-key <OPENAI_API_KEY>)
openai api fine_tunes.list

# Retrieve the state of a fine-tune. The resulting object includes
# job status (which can be one of pending, running, succeeded, or failed)
# and other information
# 检索作业状态
openai api fine_tunes.get -i <YOUR_FINE_TUNE_JOB_ID>

# Cancel a job 取消作业
openai api fine_tunes.cancel -i <YOUR_FINE_TUNE_JOB_ID>

等待训练

训练中

训练完成

使用

完成训练后可以通过模型名称来调用训练好的模型(注:采用训练模型返回的结果均为训练数据类的结果集,chatgpt会根据问题的语义进行结果集的匹配,所以训练数据决定了训练模型的精确度和范围)

CLI (windows需要加 --api-key

1
openai api completions.create -m <模型名称> -p "你的问题"

cURL

1
2
3
4
curl https://api.openai.com/v1/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": YOUR_PROMPT, "model": FINE_TUNED_MODEL}'

Pyton

1
2
3
4
import openai
openai.Completion.create(
model=FINE_TUNED_MODEL,
prompt=YOUR_PROMPT)

Node.js:

1
2
3
4
const response = await openai.createCompletion({
model: FINE_TUNED_MODEL
prompt: YOUR_PROMPT,
});

删除

如果不再使用以上模型,可以进行删除

CLI

1
openai api models.delete -i <FINE_TUNED_MODEL>

CURL

1
2
curl -X "DELETE" https://api.openai.com/v1/models/<FINE_TUNED_MODEL> \
-H "Authorization: Bearer $OPENAI_API_KEY"

Python:

1
2
import openai
openai.Model.delete(FINE_TUNED_MODEL)

kaggle

这里再介绍个网站kaggle

该网站可以摘到很多高质量数据集,我们可以借助它来生成最终的promt

比如下图,我们找到nba【1996-2021】赛季每个球员的基准数据,然后再把csv数据发给chatGpt,让它生成{"prompt":"xxx", "completion":" xxx"}格式,就可以做到“模型生产模型”

附件

kaggle航班数据地址:https://www.kaggle.com/datasets/hassanamin/atis-airlinetravelinformationsystem

规范后的示例jsonl文件,点击此处进行下载