一、创建uni-app
我用的是vue-cli命令行创建uniapp项目。
踩坑1:执行命令报错了
npm ERR! Darwin 20.6.0
npm ERR! argv "/Users/zhuzhu/.nvm/versions/node/v6.2.0/bin/node" "/Users/zhuzhu/.nvm/versions/node/v6.2.0/bin/npm" "install"
npm ERR! node v6.2.0
npm ERR! npm v3.8.9
npm ERR! This request requires auth credentials. Run `npm login` and repeat the request.
npm ERR!
npm ERR! If you need help, you may report this error at:
npm ERR! <https://github.com/npm/npm/issues>
npm ERR! Please include the following file with any support request:
npm ERR! /Users/zhuzhu/Downloads/uni-preset-vue-vite/npm-debug.log
解决:直接访问官网的gitee,下载模板,然后npm install,之后在npm run XX运行你想要的程序就好啦。
二、开发聊天功能
实现思路
之前开发的是网页版的,现在要改成小程序,接口是算法已经写好的,直接拿来了。前端这块实现最重要的是success回调里的代码,接口返回的是流式(如图一),然后前端通过截取最后一次对话内容,通过startTyping方法实现打字机效果

上代码(样式和方法可直接copy用)
<template>
<view class="main-dislogue">
<view class="header-suspension">
<view class="record-btn">悬浮</view>
</view>
<view class="content" ref="QAContent">
<scroll-view id="scrollpage" :scroll-top="scrollTop" :scroll-y="true">
<view v-for="item in dest" :key="item.id" id="msglistview">
<view class="ask" v-if="item.flag != 1">
<view class="ask-text">
<view class="ask-desc" style="word-break: break-all;">
{{ item.content }}
</view>
</view>
<text class="ask-bulge"></text>
<view class="ask-avatar">
<image class="ask-sex" v-if="sex == 1" src="/static/boy.png" fit="contain"></image>
<image class="ask-sex" v-if="sex == 2" src="/static/girl.png" fit="contain"></image>
</view>
</view>
<view class="answer">
<view class="answer-avatar">
<image class="answer-ai" src="/static/ai.png" fit="contain"></image>
</view>
<text class="answer-bulge"></text>
<view class="answer-text">
<view class="answer-desc" ref="copyAiContent">{{item.ai_content}}</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view class="bottom">
<input :cursorSpacing="20" class="bottom-input" name="name" placeholder="请输入" v-model="value"/>
<button class="bottom-button" type="primary" :disabled="isSend" @click="handleSend">发送</button>
</view>
</view>
</template>
<style>
.main-dislogue {
height: calc(100vh - 70px);
background: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 头部悬浮 */
.header-suspension {
width: 100rpx;
height: 300rpx;
/* pointer-events: none; */
z-index: 100;
position: fixed;
right: 10rpx;
bottom: 300rpx;
}
.head-image {
width: 74rpx;
height: 74rpx;
z-index: 99;
background: #d4d4d4;
border-radius: 50%;
padding: 6rpx;
box-shadow: 0px 2rpx 20rpx rgba(0, 0, 0, 0.5);
}
.record-btn {
width: 74rpx;
height: 74rpx;
background: #FFFFFF;
border-radius: 50%;
font-size: 26rpx;
text-align: center;
padding: 6rpx;
box-shadow: 0px 2rpx 20rpx rgba(0, 0, 0, 0.5);
color: #4A90E2;
margin-top: 29rpx;
}
/* 内容 */
.content {
padding: 12rpx;
padding-bottom: 100px;
background: #f5f5f5;
}
/* #scrollpage {
} */
/* 问 */
.ask {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: 6rpx;
}
.ask-avatar {
width: 120rpx;
margin-top: 20rpx;
}
.ask-sex {
width: 100rpx;
height: 100rpx;
}
.ask-bulge {
position: relative;
top: 41rpx;
right: 23rpx;
display: block;
width: 0;
height: 0;
border: 15rpx solid #38a579;
transform: rotate(45deg);
}
.ask-text {
z-index: 1;
}
.ask-desc {
background: #38a579;
border-radius: 13rpx;
padding: 15rpx;
line-height: 58rpx;
margin-top: 27rpx;
white-space: pre-line;
word-break: break-all;
color: #fff;
margin-left: 124rpx;
}
/* 答 */
.answer {
display: flex;
justify-content: flex-start;
margin-top: 6rpx;
}
.answer-avatar {
width: 120rpx;
margin-top: 20rpx;
}
.answer-ai {
width: 100rpx;
height: 100rpx;
}
.answer-bulge {
position: relative;
top: 41rpx;
left: 23rpx;
display: block;
width: 0;
height: 0;
border: 15rpx solid #ffffff;
transform: rotate(45deg);
}
.answer-text {
z-index: 1;
}
.answer-desc {
margin-right: 88rpx;
border-radius: 13rpx;
line-height: 58rpx;
background: #fff;
margin-top: 27rpx;
tab-size: 12rpx;
padding: 15rpx;
white-space: pre-wrap;
box-shadow: 0rpx 5rpx 47rpx 0rpx #97979773;
}
/* 尾部 */
.bottom {
border-top: 2rpx solid #CCCCCC;
background: #f5f5f5;
display: flex;
padding: 10rpx;
padding-bottom: 50rpx;
position: fixed;
bottom: 0;
z-index: 99;
width: 100%;
}
.bottom-input {
flex: 1;
font-size: 35rpx;
border-radius: 10rpx;
background: #FFFFFF;
padding: 17rpx;
}
.bottom-button {
width: 190rpx;
height: 80rpx;
font-size: 14px;
line-height: 80rpx;
margin-left: 20rpx;
background: #4A90E2 !important;
}
</style>
<script>
import Api from "@/utils/api.js";
import base from '@/utils/base.js';
const BASE_URL = base.baseUrl;
const recorderManager = uni.getRecorderManager()
export default {
data() {
return {
sex: "",
birthDate: "",
generateRecordsFlag: false,
dest: [],
dialogue_code: "",
value: "",
isSend: false,
scrollTop: 0,
currentText: "",
isSpeaking: false
}
},
onLoad(option) {
this.sex = option.sex;
this.birthDate = option.birthDate;
this.dialogue_code = option.dialogue_code;
},
onReady() {
let _this = this;
uni.getStorage({
key: 'gpt_h5_dialogue',
success: function (res) {
let list = res.data || "";
if (list.length) {
this.dest = JSON.parse(list);
if (this.dest.length >= 2) {
this.generateRecordsFlag = true;
}
} else {
setTimeout(() => {
_this.handleSend();
}, 500)
}
}
});
},
methods: {
// 年龄转换
ageCalculation(date) {
var today = new Date();
// 获取出生日期
var birthDate = new Date(date); // 假设出生日期为1990年1月1日
// 计算年龄
var age = today.getFullYear() - birthDate.getFullYear();
var m = today.getMonth(), d = today.getDate();
if (m < birthDate.getMonth()) {
age--;
} else if (m === birthDate.getMonth() && d < birthDate.getDate()) {
age--;
}
return age;
},
// 发送聊天
async handleSend() {
this.preEventSource && this.preEventSource?.close();
if (this.dest.length != 0 && !this.value) {
return;
}
let _this = this;
let { prompt, model } = await Api.getPromptList({ type: 1 });
let sex = this.sex == 1 ? "男" : "女";
let age = this.ageCalculation(this.birthDate);
prompt = prompt.replace('{age}', `${age}岁`).replace('{sex}', `${sex}性`);
let obj = {
ai_content: "...",
chat_model: model,
content: prompt,
create_time: "2024-01-05T06:55:29.000Z",
dialogue_code: this.dialogue_code,
id: 450,
req_time: "2024-01-05T06:55:30.000Z",
res_time: null,
tags: null,
user_code: "00468",
flag: 1
};
const diaObj = {
content: this.value,
ai_content: "...",
chat_model: model,
create_time: new Date(),
dialogue_code: this.dialogue_code,
id: Date.now(),
tags: null,
user_code: "00468",
loading: false,
flag: 2
};
if (this.dest.length == 0) {
// 第一次
this.dest.push(obj);
} else {
this.dest.push(diaObj);
}
let params = {
"dialogue_code": this.dialogue_code,
"content": this.value || this.dest[0].content,
"chat_model": model
}
this.isSend = true;
_this.scrollToBottom();
let ai_content = "", startFlag = false;
this.value = ""; // 置空输入框
// 从这往上可以忽略,这是我业务逻辑,不必关注。重点是uni.request success回调内容
uni.request({
url: `${BASE_URL}/hmgpt/dialogue`,
data: params,
method: "POST",
headers: {
"Content-Type": 'application/json',
},
success: (res) => {
let str = JSON.stringify(res.data);
// 将字符串按"data: ["分割,然后取最后一个部分
const lastDataSection = str.split("data: [").pop();
// 截取最后一个JSON对象的部分
const lastJsonString = lastDataSection.split("]")[0].replace(/\\/g, '');
// 解析JSON字符串
const lastJsonObject = JSON.parse(lastJsonString);
// 获取ai_content的值
const lastAiContent = lastJsonObject.ai_content;
console.log(lastAiContent, 'lastAiContent');
ai_content = lastAiContent;
if (lastAiContent == "") {
// 返回空,则默认提示
ai_content = "目前公司GPU服务器有限,会因为调试需要临时中断出现服务不可用,请稍后重试。";
}
_this.dest[_this.dest.length - 1].ai_content = "";
if (!startFlag) {
startFlag = true
startTyping();
}
}
});
function startTyping() {
let currentIndex = 0;
const typingSpeed = 100; // 打字速度,单位:毫秒
const timer = setInterval(() => {
_this.dest[_this.dest.length - 1].ai_content += ai_content[currentIndex];
currentIndex++;
_this.scrollToBottom();
if (currentIndex >= ai_content.length) {
clearInterval(timer);
_this.isSend = false;
}
}, typingSpeed);
uni.setStorage({
key: 'gpt_h5_dialogue',
data: JSON.stringify(_this.dest),
success: function () { }
});
}
},
// 滚动至聊天底部
scrollToBottom() {
this.$nextTick(() => {
const query = uni.createSelectorQuery();
query.select('#scrollpage').boundingClientRect();
query.exec(res => {
this.scrollTop = res[0].height;
uni.pageScrollTo({
scrollTop: res[0].height + 170, // 将滚动位置设置为顶部
duration: 300 // 滚动到顶部的动画时长,单位为毫秒
});
})
})
}
},
}
</script>
效果图
