You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

530 lines
14 KiB
Vue

<template>
<view class="ai-page">
<page-meta :page-style="'overflow:' + (show ? 'hidden' : 'visible')"></page-meta>
<top @clickLeft="openDrawer" @resetMessage="resetMessage"></top>
<scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
<front @onSuggestionTap="onQuickAsk" />
<chat :messages="messages" @continueCreate="continueCreate" :isReplying="isReplying" @refresh="refresh"
@changeShow="changeShow" @changeInputText="changeInputText" @handleVoice="handleVoice"
:isPlayingVoice="isPlayingVoice" :playSrc="playSrc" />
</scroll-view>
<view :style="{ height: marginBottom + 'px', backgroundColor: '#fff' }" />
<leftDrawer :historyGroups="historyGroups" ref="popup" @changeShow="changeShow"
@onHistoryItemTap="onHistoryItemTap" @removeFromHistory="removeFromHistory"
@clearAllHistory="clearAllHistory" />
<search ref="searchRef" :inputText="inputText" @onSend="onSend" @onQuickAsk="onQuickAsk"
@changeInputText="changeInputText" :isReplying="isReplying" @handleBreak="handleBreak"
@changeShow="changeShow" @startRecord="startRecord"/>
</view>
</template>
<script>
const HISTORY_KEY = "chat_history_groups";
import {
getAIResponse,
} from "@/api/index.js";
import {
textToSpeech,
base64ToFile,
removeFile
} from '@/utils/utils.js'
import top from "./top/index.vue";
import front from "./front/index.vue";
import chat from "./chat/index.vue";
import leftDrawer from "./leftDrawer/index.vue";
import search from "./search/index.vue";
export default {
components: {
top,
front,
chat,
leftDrawer,
search
},
data() {
return {
inputText: "",
messages: [],
historyGroups: [],
isLoading: false,
typewriterTimers: {},
show: false,
marginBottom: 0,
isReplying: false, // 是否正在回复
breakReplying: false, // 是否打断回复
replyData: {}, // 回复数据
isRefresh: false, // 是否重新回复
audioContext: null,
isPlayingVoice: false, // 是否正在播放语音
playSrc: '',
breakRequestList: [],
speechIdList: [],
textToVoiceLoading: false
};
},
async mounted() {
this.loadChatHistory();
uni.onKeyboardHeightChange((res) => {
uni.pageScrollTo({
scrollTop: this.height + res.height,
duration: 300,
class: ".content",
});
});
// #ifdef APP-PLUS
this.$nextTick(() => {
this.marginBottom = this.$refs.searchRef.getHeight() || 103;
});
// #endif
this.marginBottom = 103;
this.initAudio();
},
beforeDestroy() {
this.clearTypewriterTimers();
},
methods: {
// 开始录音后暂停播放语音
startRecord(){
if(this.isPlayingVoice && this.audioContext.src){
this.audioContext.stop();
this.isPlayingVoice = false;
}
},
// 点击语音播放暂停
handleVoice(src) {
if (this.audioContext.src === src && this.isPlayingVoice) {
this.isPlayingVoice = false;
this.audioContext.stop();
return;
};
if (this.audioContext.src === src && !this.isPlayingVoice) {
this.isPlayingVoice = true;
this.audioContext.play();
return;
};
if (this.isPlayingVoice) {
this.audioContext.stop();
};
this.isPlayingVoice = true;
this.audioContext.src = src;
this.playSrc = src;
this.audioContext.play();
},
// 初始化语音功能, 已经语音部分监听
initAudio() {
this.audioContext = uni.createInnerAudioContext()
this.audioContext.onCanplay((e) => {
let items = this.messages.find(item => item.src && item.src === this.audioContext.src);
if (!items) return;
items.duration = this.audioContext.duration;
// this.isPlayingVoice = true;
// this.audioContext.play()
});
this.audioContext.onEnded(res => {
this.isPlayingVoice = false
// const platform = uni.getSystemInfoSync().uniPlatform;
// if(platform === 'web') return;
// removeFile(this.audioContext.src)
})
},
// 处理百度返回arraybuff格式的语音
getSpeech(speechStr) {
let self = this;
this.textToVoiceLoading = true;
let startTime = Date.now();
return new Promise((resolve) => {
textToSpeech(speechStr).then(audioData => {
let endTime = Date.now();
console.log('语音合成耗时:', endTime - startTime, 'ms');
const platform = uni.getSystemInfoSync().uniPlatform;
// H5端处理
if (platform === 'web') {
const blob = new Blob([audioData], {
type: 'audio/mp3'
});
const url = URL.createObjectURL(blob);
this.playSrc = url;
this.audioContext.src = url;
this.textToVoiceLoading = false;
resolve(true)
}
// App端和小程序端处理
else {
const base64Audio = uni.arrayBufferToBase64(audioData);
const base64WithPrefix = `data:audio/mp3;base64,${base64Audio}`;
const fileName = `_doc/${Date.now()}_numberPerson.mp3`;
base64ToFile(base64WithPrefix, fileName, (path) => {
this.playSrc = path;
self.textToVoiceLoading = false;
self.audioContext.src = path;
let endTime2 = Date.now();
console.log('语音下载到手机耗时:', endTime2 - startTime, 'ms');
resolve(true)
});
}
}).catch(err => {
resolve(true)
})
})
},
// 重新回复
refresh() {
this.messages.splice(this.messages.length - 1, 1);
this.inputText = this.messages[this.messages.length - 1].content;
this.isRefresh = true;
this.onSend();
},
// 继续生成
continueCreate() {
this.breakReplying = false;
this.isReplying = true;
if (!this.isLoading) {
this.messages[this.messages.length - 1].isBreak = false;
this.replyAction(this.replyData, true)
} else {
this.messages.splice(this.messages.length - 1, 1);
this.messages.push({
id: this.loadingId,
role: "assistant",
loading: true,
});
}
},
// 中断回复
handleBreak() {
if (this.isLoading) {
const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. 添加回复 + 打字机
const replyId = this.baseId + 1;
this.messages.push({
id: replyId,
role: "assistant",
type: "text",
content: '',
displayText: "已停止",
isBreak: true
});
this.isReplying = false;
this.scrollToBottom();
}
this.breakReplying = true;
},
// 新建对话
resetMessage() {
if (this.messages.length === 0) return;
if (this.isLoading) {
this.breakRequestList.push({
requestId: this.requestId
});
};
if (this.textToVoiceLoading) {
this.speechIdList.push({
speedId: this.speedId
})
};
if (this.isPlayingVoice) {
this.isPlayingVoice = false;
this.audioContext.stop();
}
this.clearTypewriterTimers();
this.isReplying = false;
this.messages = [];
},
// 清理所有打字机定时器
clearTypewriterTimers() {
Object.values(this.typewriterTimers).forEach((timer) => {
if (timer) clearInterval(timer);
});
this.typewriterTimers = {};
},
// 修改输入框文本
changeInputText(text) {
this.inputText = text;
},
//打开历史记录
openDrawer() {
this.$refs.popup.open();
},
// 点击历史记录搜索
onHistoryItemTap(text) {
this.inputText = text;
this.onSend();
this.$refs.popup.close();
},
// 聊天页面禁止滚动
changeShow(e) {
this.show = e;
},
// 删除历史记录
removeFromHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
groups.forEach((group) => {
group.items = group.items.filter((item) => item !== text);
});
groups = groups.filter((g) => g.items.length > 0);
this.historyGroups = groups;
uni.setStorageSync(HISTORY_KEY, {
groups,
updatedAt: Date.now(),
});
},
// 清除全部历史记录
clearAllHistory() {
uni.showModal({
title: "清除全部",
content: "将删除所有对话记录,此操作不可恢复",
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(HISTORY_KEY);
this.historyGroups = [];
uni.showToast({
title: "已清除",
icon: "success",
});
}
},
});
},
formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}${m}${d}`;
},
// 加载历史记录
loadChatHistory() {
try {
const data = uni.getStorageSync(HISTORY_KEY);
if (data && Array.isArray(data.groups)) {
this.historyGroups = data.groups;
} else {
this.historyGroups = [];
}
} catch (e) {
this.historyGroups = [];
}
},
// 添加历史记录
addToHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
const today = this.formatDate(new Date());
let todayGroup = groups.find((g) => g.date === today);
if (!todayGroup) {
todayGroup = {
date: today,
items: [],
};
groups.unshift(todayGroup);
}
if (!todayGroup.items.includes(text)) {
todayGroup.items.unshift(text);
}
// 限制大小
if (todayGroup.items.length > 50)
todayGroup.items = todayGroup.items.slice(0, 50);
if (groups.length > 30) groups = groups.slice(0, 30);
this.historyGroups = groups;
uni.setStorageSync(HISTORY_KEY, {
groups,
updatedAt: Date.now(),
});
},
// 快速提问
onQuickAsk(text) {
this.inputText = text;
this.onSend();
},
// 发送消息
async onSend(inputType = "text", inputContent = "", duration = undefined) {
if (this.isReplying) return;
const text = (this.inputText || "").trim();
if (!text || this.isLoading) return;
this.baseId = Date.now();
if (!this.isRefresh) {
// 1. 用户消息
this.messages.push({
id: this.baseId,
role: "user",
type: "text",
content: text,
inputType: typeof inputType === "string" ? inputType : "text",
inputContent,
duration,
});
}
// 2. loading 消息
this.loadingId = this.baseId + 0.5;
this.messages.push({
id: this.loadingId,
role: "assistant",
loading: true,
});
this.inputText = "";
this.isReplying = true;
this.isLoading = true;
this.isRefresh = false;
this.scrollToBottom();
this.addToHistory(text);
if (this.isPlayingVoice) {
this.audioContext.stop();
this.isPlayingVoice = false;
};
let requestId = Date.now();
this.requestId = requestId;
// 3. 真正等待 AI 回复
const reply = await getAIResponse({
message: text,
});
this.isLoading = false;
let requestIndex = this.breakRequestList.findIndex(item => item.requestId === requestId)
if (requestIndex > -1) {
this.breakRequestList = this.breakRequestList.splice(requestIndex, -1)
return;
}
this.replyData = reply;
if (this.breakReplying) {
this.breakReplying = false;
return;
}
this.replyAction(reply)
},
async replyAction(reply, isCreate) {
if (!this.messages[this.messages.length - 1].src) {
let speechId = Date.now();
this.speechId = speechId;
const result = await this.getSpeech(reply);
let speechIndex = this.speechIdList.findIndex(item => item.speechId === speechId)
if (speechIndex > -1) {
this.speechIdList = this.speechIdList.splice(speechIndex, -1)
return;
}
if (result) {
this.isPlayingVoice = true;
this.audioContext.play();
};
};
let content = ''
if (reply.errMsg) {
content = `请求出错: ${reply.errMsg}`
} else {
content = reply
};
// 4. 移除 loading
const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. 添加回复 + 打字机
const replyId = this.baseId + 1;
if (!isCreate) {
this.messages.push({
id: replyId,
role: "assistant",
type: "text",
content,
displayText: "",
src: JSON.parse(JSON.stringify(this.audioContext.src)),
duration: null
});
}
this.$nextTick(() => this.scrollToBottom());
this.typewriter(replyId, content);
},
// 打印机效果,并清除加载动画
typewriter(messageId, fullText) {
const msg = this.messages.find((m) => m.id === messageId);
if (!msg) return;
// 清理之前的定时器(如果存在)
if (this.typewriterTimers[messageId]) {
clearInterval(this.typewriterTimers[messageId]);
}
let index = msg.displayText.length;
msg.displayText = fullText.substring(0, index + 1);
index += 1;
const speed = 50; // 每个字符间隔50ms
const timer = setInterval(() => {
// 是否中断
if (this.breakReplying) {
clearInterval(timer);
delete this.typewriterTimers[messageId];
msg.isBreak = true;
this.isReplying = false;
this.breakReplying = false
this.isLoading = false;
this.isPlayingVoice = false;
this.audioContext.stop();
this.scrollToBottom();
}
if (index < fullText.length) {
msg.displayText = fullText.substring(0, index + 1);
index++;
this.scrollToBottom();
} else {
clearInterval(timer);
delete this.typewriterTimers[messageId];
// 完成后使用完整文本
msg.displayText = fullText;
this.isReplying = false;
this.breakReplying = false;
this.$nextTick(() => {
this.scrollToBottom();
});
}
}, speed);
this.typewriterTimers[messageId] = timer;
},
// 滚动到底部
scrollToBottom() {
let self = this;
this.$nextTick(() => {
uni
.createSelectorQuery()
.select(".content")
.boundingClientRect((rect) => {
if (self.height !== rect.height) {
self.height = rect.height;
uni.pageScrollTo({
scrollTop: rect.height,
duration: 300,
class: ".content",
});
}
})
.exec();
});
},
},
};
</script>
<style scoped>
::v-deep .uni-nav-bar-text {
font-size: 18px !important;
}
::v-deeo .uni-navbar--border {
border-bottom: 1px solid #fff !important;
}
.ai-page {
display: flex;
flex-direction: column;
background: #f7f8fc;
}
.content {
flex: 1;
padding: 16px 12px 0px 12px;
background-color: #f7f8fc;
width: 100%;
box-sizing: border-box;
}
</style>