各位前后端全栈开发大家好,我今天讲讲我的开源项目如何在业务里使用AI,让AI贴进业务,ChatGpt他们开发AI大模型,我们则可以让AI加入到应用,让AI平民化。
前端:vue3、typescript、vite、websocket等
后端:nestJs、redis、mysql、websocket等
AI大模型:星火AI
先看业务截图,业务截图中,input输入框直接点击按钮,有AI续写和AI英语翻译,这两个小功能是已固定好的话术,点击后就可以去续写文案和翻译文案。旁边还有个AI对话弹框,可自定义话术培养AI数据大模型,和我们使用的AI库对话是一样的,但是对接的API只有文字,暂时没有生成/理解图片、生成视频、生成PPT等其它API。
当然,AI大模型私有化部署更好,这样就不用购买tokens了
首先在科大讯飞里购买星火AI大模型的tokens,我这里用的是3.0版本,其它版本或者其它API请自行查看文档
购买好tokens后先在后端写好websocket的业务代码,则在项目里安装几个依赖,@nestjs/common、@nestjs/websockets、ws、crypto-js、rxjs、rxjs/operators。
websocket的后端适配代码,在main.ts引入注册
typescript
import WebSocket from 'ws';
import { WebSocketAdapter, INestApplicationContext } from '@nestjs/common';
import { MessageMappingProperties } from '@nestjs/websockets';
import { Observable, fromEvent, EMPTY } from 'rxjs';
import { mergeMap, filter } from 'rxjs/operators';
export class WsAdapter implements WebSocketAdapter {
constructor(private app: INestApplicationContext) { }
create(port: number, options: any = {}): any {
console.log('ws create')
return new WebSocket.Server({ port, ...options });
}
bindClientConnect(server, callback: Function) {
console.log('ws bindClientConnect, server:\n', server);
server.on('connection', callback);
}
bindMessageHandlers(
client: WebSocket,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
) {
console.log('[waAdapter]有新的连接进来')
fromEvent(client, 'message')
.pipe(
mergeMap(data => this.bindMessageHandler(client, data, handlers, process)),
filter(result => result),
)
.subscribe(response => client.send(JSON.stringify(response)));
}
bindMessageHandler(
client: WebSocket,
buffer,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
): Observable<any> {
let message = null;
try {
message = JSON.parse(buffer.data);
} catch (error) {
console.log('ws解析json出错', error);
return EMPTY;
}
const messageHandler = handlers.find(
handler => handler.message === message.event,
);
if (!messageHandler) {
return EMPTY;
}
return process(messageHandler.callback(message.data));
}
close(server) {
console.log('ws server close');
server.close();
}
}
main.ts文件
javascript
import { WsAdapter } from './common/ws/ws.adapter';
app.useWebSocketAdapter(new WsAdapter(app));
然后再来写对应的业务
typescript
import { ConnectedSocket, MessageBody, SubscribeMessage, WebSocketGateway } from "@nestjs/websockets";
import WebSocket from 'ws';
import CryptoJS from 'crypto-js';
@WebSocketGateway(3002)
export class WsStartGateway {
private ttsWS = null; // websocket实例
private connectionState = false // AI大模型状态
private resText = ''; // 所有结果最后拼接的文本
private APPID = ''; // 星火AI的APPID
private API_SECRET = ''; // 星火AI的API_SECRET
private API_KEY = ''; // 星火AI的API_KEY
private websocketUrl = ''; // 星火AI的websocket的url
/** 普通的AI方法 */
@SubscribeMessage('aiEvent')
aiEvent(@MessageBody() data: any, @ConnectedSocket() client: WebSocket): any {
console.log('收到消息 client:', data, 'aiEvent');
this.resText = '';
const wsUrl = this.getAiWebsocketUrl();
const ttsWs = new WebSocket(this.websocketUrl);
this.ttsWS = ttsWs;
if (data === '') {
client.send(JSON.stringify({ event: 'tmp', data: '请输入文本', code: 500}));
return false
}
ttsWs.onopen = e => {
this.webSocketSend(data)
}
ttsWs.onmessage = e => {
let jsonData = JSON.parse(e.data);
this.result(e.data)
}
ttsWs.onerror = e => {
console.error(`详情查看:${e}`)
}
ttsWs.onclose = e => {
// 接收完AI返回的所有结果后,一次性拼接文字并返回,无需和AI对话框一样
client.send(JSON.stringify({ event: 'tmp', data: this.resText }));
}
}
// websocket接收数据的处理 - 这个方法可以写在service层里
result(resultData) {
let jsonData = JSON.parse(resultData)
this.resText = this.resText + jsonData.payload.choices.text[0].content;
// 提问失败
if (jsonData.header.code !== 0) {
console.error(`提问失败:${jsonData.header.code}:${jsonData.header.message}`)
}
if (jsonData.header.code === 0 && jsonData.header.status === 2) {
this.ttsWS.close(); // jsonData.header.status === 2,接收完毕,执行关闭websocket服务的方法
this.connectionState = true;
}
}
// websocket发送数据 - 这个方法可以写在service层里
webSocketSend(text) {
let params = {
"header": {
"app_id": this.APPID,
},
"parameter": {
"chat": {
"domain": "generalv3",
"temperature": 0.5,
"max_tokens": 2048
}
},
"payload": {
"message": {
"text": [
{
"role": "user",
"content": `${text}`
}
]
}
}
}
this.ttsWS.send(JSON.stringify(params))
}
// 生成星火大模型的API - 这个方法可以写在service层里
getAiWebsocketUrl() {
const date = new Date().toUTCString().replace("GMT", "+0000");
let header = "host: spark-api.xf-yun.com\n"
header += "date: " + date + "\n"
header += `GET /v3.1/chat HTTP/1.1`
const hmacSHA = CryptoJS.HmacSHA256(header, this.API_SECRET);
const base64 = CryptoJS.enc.Base64.stringify(hmacSHA)
const authorization_origin = `api_key="${this.API_KEY}", algorithm="hmac-sha256", headers="host date request-line", signature="${base64}"`
const authorization = btoa(authorization_origin);
const url = `wss://spark-api.xf-yun.com/v3.1/chat?authorization=${authorization}&date=${encodeURIComponent(date)}&host=spark-api.xf-yun.com`
this.websocketUrl = url;
return url;
}
}
在app.module.ts里引入并全局,ps:已经省略了一大段代码
python
import { WsStartGateway } from './common/ws/ws.gateway'
@Module({
imports: [
providers: [WsStartGateway]
],
})
接下来写前端代码,AI业务的前端代码我写了一个方法来使用,续写和英语翻译需要准备好话术,不喜欢三元运算符的朋友可以使用map、if等其它也行,代码如下:
typescript
/*
* @Descripttion: 封装socket方法
* @version: 1.0
* @Date: 2024-01-22 17:03
* @LastEditTime: 2024-01-22 18:00
* @params:
* -type: xuxie(续写)、fanyi(翻译)
* -content: 文本
*/
import appConfig from '@/config/index' // 获取在配置文件里的websocketApi
import { type BaseResult } from '@/api/base' // ts类型
export interface aiResult extends BaseResult {
temp: any
data: any
}
// AI续写和英语翻译
export const aiSocket = (type: string, content: string | any) => {
const connectURL = `${appConfig.api.websocketUrl}/aiEvent`;
let websocket: any;
return new Promise<string>((resolve, reject) => {
// 提交参数
let text = type === 'xuxie' ? `${content}。请帮我续写这段话`
: type === 'fanyi' ? `${content}。请帮我把这段话翻译成英语,并返回英语`
: type === 'code' ? content
: '';
const submitData = {
event: 'aiEvent', // websocket的后端接口
data: text
}
websocket = new WebSocket(connectURL);
websocket.onopen = (e: object) => {
sendWs(submitData);
}
websocket.onmessage = (e: any) => {
// 一次性接收,无需和AI对话框一样
let jsonData = JSON.parse(e.data);
resolve(jsonData)
websocket.close()
}
websocket.onclose = (e: object) => {
console.log('关闭websocket', e)
}
websocket.onerror = (e: object) => {
console.log('websocket错误', e)
reject(e);
}
const sendWs = (data: object) => {
websocket.send(JSON.stringify(data))
}
})
}
// AI对话弹框
export const aiDialogSocket = (content: string | any) => {
const connectURL = `${appConfig.api.websocketUrl}/aiEvent`;
let websocket: any;
return new Promise<string>((resolve, reject) => {
// 提交参数
const submitData = {
event: 'aiEvent', // websocket的后端接口
data: content
}
websocket = new WebSocket(connectURL);
websocket.onopen = (e: object) => {
sendWs(submitData);
}
websocket.onmessage = (e: any) => {
// 一次性接收,无需和AI对话框一样
let jsonData = JSON.parse(e.data);
resolve(jsonData)
websocket.close()
}
websocket.onclose = (e: object) => {
console.log('关闭websocket', e)
}
websocket.onerror = (e: object) => {
console.log('websocket错误', e)
reject(e);
}
const sendWs = (data: object) => {
websocket.send(JSON.stringify(data))
}
})
}
封装好就来写前端的页面,代码如下
ini
<template>
<el-dialog
title="内容添加"
v-model="visible"
top="10vh"
width="90%"
:before-close="handleClose"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-row :gutter="20">
<el-col :span="16">
<el-form
ref="dataFormRef"
:model="dataForm"
:rules="dataFormRules"
label-position="right"
label-width="120px"
v-loading="loading"
>
<el-tabs
v-model="activeNames"
class="tabs"
>
<el-tab-pane label="内容配置" name="1">
<el-form-item label="内容标题" prop="title">
<el-input v-model="dataForm.title" placeholder="请输入内容标题"></el-input>
<el-button @click="aiEvent('xuxie', 'title', '标题')">AI续写</el-button>
<el-button @click="aiEvent('fanyi', 'title', '标题')">AI英语翻译</el-button>
</el-form-item>
<el-form-item label="内容简介" prop="intro">
<el-input type="textarea" v-model="dataForm.intro" placeholder="请输入内容简介"></el-input>
<el-button @click="aiEvent('xuxie', 'intro', '简介')">AI续写</el-button>
<el-button @click="aiEvent('fanyi', 'intro', '简介')">AI英语翻译</el-button>
</el-form-item>
<el-form-item label="内容栏目" prop="columnId">
<el-select v-model="dataForm.columnId" class="m-2" placeholder="请选择栏目" size="large">
<el-option v-for="item in columnAllList" :key="item.id" :label="item.title" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="内容标签" prop="tagsList">
<el-checkbox-group v-model="tagsList">
<el-checkbox v-for="item in tagsAllList" :key="item.id" :label="item.id">
{{ item.title }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="内容推荐" prop="recomList">
<el-checkbox-group v-model="recomList">
<el-checkbox label="recom">推荐</el-checkbox>
<el-checkbox label="frontPage">头条</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="内容发布时间" prop="publishDate">
<el-date-picker
v-model="dataForm.publishDate"
type="datetime"
placeholder="请选择发布时间"
/>
</el-form-item>
<el-form-item label="内容排序" prop="orderNum">
<el-input-number
v-model.number="dataForm.orderNum"
:min="0"
:step="1"
:precision="0"
step-strictly
></el-input-number>
</el-form-item>
<el-form-item label="内容seo关键词" prop="keyword">
<el-input v-model="dataForm.keyword" placeholder="请输入内容seo关键词"></el-input>
</el-form-item>
<el-form-item label="内容seo描述" prop="description">
<el-input type="textarea" v-model="dataForm.description" placeholder="请输入内容seo描述"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="内容详情" name="2">
<el-form-item label="内容价格" prop="price">
<el-input-number
v-model.number="dataForm.price"
:min="0"
:step="0.01"
:precision="2"
step-strictly
></el-input-number>
</el-form-item>
<el-form-item label="内容原价" prop="originalPrice">
<el-input-number
v-model.number="dataForm.originalPrice"
:min="0"
:step="0.01"
:precision="2"
step-strictly
></el-input-number>
</el-form-item>
<el-form-item label="内容图片组合" prop="img">
<div class="uploadBox">
<el-upload
:file-list="fileList"
class="img-uploader"
action="#"
:auto-upload="false"
list-type="picture-card"
:on-change="uploadCodeClickEvent"
:on-preview="handlePictureCardPreview"
:on-remove="handlePictureCardDelete"
>
<el-icon class="img-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
<el-button type="primary" @click="clickOssEvent">从素材库选择</el-button>
</el-form-item>
<el-alert title="默认第一张为内容封面图" type="warning" :closable="false" />
<el-form-item label="内容详情" prop="content">
<div ref="richTextRef" style="height: 600px"></div>
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
</el-col>
<el-col :span="8">
<div class="dialogue">
<div class="title">大山AI对话框</div>
<div class="dialogueBox">
<div class="dialogueItems">
<div class="dialogueItem" v-for="(item, index) in aiList" :key="index">
<SvgIcon name="ai" />
<div class="content">
{{ item.content }}
</div>
</div>
</div>
<div class="aiDialog">
<div class="btnBox">
<el-button round size="small" @click="clearDialog">清空对话框</el-button>
<el-button round type="primary" size="small" v-for="item in aiBtnList" :key="item.keys" @click="insertionContent(item.keys)">赋予{{ item.label }}</el-button>
</div>
<el-alert title="赋予AI内容的时候是最新的一条,如需其它内容,请手动复制" type="warning" :closable="false" />
<div class="content">
<el-input type="textarea" v-model="aiContent" placeholder="请输入内容"></el-input>
<div class="submintBtn">
<el-button type="primary" :loading="aiBtnLoading" round @click="submitAiDialog">
<el-icon><Position /></el-icon>
{{ !aiBtnLoading ? '发送' : 'AI生成中' }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="updateOrCreate">确定</el-button>
</template>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<!-- 素材库 -->
<OssWarehouse v-model="showOss" @change="enterOss"></OssWarehouse>
</el-dialog>
</template>
<script lang="ts" setup>
// 省略一大段业务代码,哈哈哈原谅我
import { aiSocket, aiDialogSocket } from '@/utils/socket'
// AI方法
const aiEvent = async (type: string, key: string, msg: string) => {
loading.value = true
let formKey = key;
// 判断不能为空,后端也可以校验,但在这里是前端校验了
if (dataForm.value[formKey] === '') {
ElMessage({ type: 'error', message: `请您输入${msg}` })
loading.value = false
return false;
}
const res = await aiSocket(type, dataForm.value[formKey]);
if (res) {
loading.value = false;
const aiResultData = res as any
dataForm.value[formKey] = `${aiResultData.data}`;
}
}
// 素材库素材回填
const showOss = ref<boolean>(false)
const clickOssEvent = () => {
showOss.value = true
}
const enterOss = (checkList: any) => {
fileList.value = [...fileList.value, ...checkList]
submotFileList.value = [...submotFileList.value, ...checkList]
}
// AI对话框列表
const aiList = ref<Array<any>>([]);
const aiContent = ref<string>('');
const aiBtnLoading = ref<boolean>(false)
// 对应要赋予值得formValue里的filed、名称
const aiBtnList = ref([
{
label: '标题',
value: '',
keys: 'title'
},
{
label: '简介',
value: '',
keys: 'intro'
},
{
label: '关键词',
value: '',
keys: 'keyword'
},
{
label: '描述',
value: '',
keys: 'description'
}
]);
// 清空AI对话框
const clearDialog = () => {
aiList.value = []
}
// 插入AI对应值
const insertionContent = (keys: string) => {
const value = aiBtnList.value.find(item => item.keys === keys)?.value;
dataForm.value[keys] = value;
}
// 发送AI内容
const submitAiDialog = async () => {
aiBtnLoading.value = true;
const res = await aiDialogSocket(aiContent.value);
if (res) {
loading.value = false;
const aiResultData = res as any
aiList.value.push({
content: `${aiResultData.data}`
});
aiBtnList.value.forEach(item => {
item.value = `${aiResultData.data}`
})
aiBtnLoading.value = false;
}
}
</script>
<style scoped>
// 省略css
</style>
业务中使用AI的代码已经贴完了,让我们看看使用
优化点:
1、封装AI对话弹框:可以将AI对话弹框可以封装成组件,写一个vue自定义的指令,在业务代码里需要AI的输入框写上自定义指令,指令里业务代码是监测到某个输入框聚焦后,获取到该输入框的位置,将AI对话弹框悬浮在该输入框右侧。
2、websocket权限:后台对不同角色/用户分配AI权限或者AI的token数,用户登录后,获取用户信息,先查角色的权限和AI的token数,如果要精确到用户,则再查一遍用户的权限和AI的token数,存在redis里。前端请求websocketApi的时候,传个用户的id,后端根据这个用户id,在redis里取出该用户的角色权限、AI的token数即可。
本人的开源项目大山后台管理系统,基于NestJs框架,包含了上面所述的功能,欢迎大家Start和体验