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.
344 lines
8.8 KiB
Vue
344 lines
8.8 KiB
Vue
<template>
|
|
<view class="ai-page">
|
|
<page-meta :page-style="'overflow:' + (show ? 'hidden' : 'visible')"></page-meta>
|
|
<top @clickLeft="openDrawer"></top>
|
|
<scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
|
|
<front @onSuggestionTap="onQuickAsk" />
|
|
<chat :messages="messages" />
|
|
</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" />
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
const HISTORY_KEY = "chat_history_groups";
|
|
import {
|
|
getAIResponse
|
|
} from "@/api/index.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";
|
|
import {
|
|
recognizeAudio
|
|
} from "@/utils/uploadVoice.js";
|
|
export default {
|
|
components: {
|
|
top,
|
|
front,
|
|
chat,
|
|
leftDrawer,
|
|
search
|
|
},
|
|
data() {
|
|
return {
|
|
inputText: "",
|
|
messages: [],
|
|
historyGroups: [],
|
|
isLoading: false,
|
|
typewriterTimers: {},
|
|
show: false,
|
|
marginBottom: 0,
|
|
isReplying: false,
|
|
breakReplying: false
|
|
};
|
|
},
|
|
mounted() {
|
|
this.loadChatHistory();
|
|
this.scrollToBottom();
|
|
let self = this;
|
|
uni.onKeyboardHeightChange((res) => {
|
|
uni.pageScrollTo({
|
|
scrollTop: this.height + res.height,
|
|
duration: 300,
|
|
class: ".content",
|
|
});
|
|
});
|
|
this.$nextTick(() => {
|
|
this.marginBottom = this.$refs.searchRef.getHeight() || 103;
|
|
});
|
|
},
|
|
beforeDestroy() {
|
|
// 清理所有打字机定时器
|
|
Object.values(this.typewriterTimers).forEach((timer) => {
|
|
if (timer) clearInterval(timer);
|
|
});
|
|
this.typewriterTimers = {};
|
|
},
|
|
methods: {
|
|
// 中断回复
|
|
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: "",
|
|
});
|
|
this.isReplying = false;
|
|
this.isLoading = false;
|
|
}
|
|
this.breakReplying = true;
|
|
},
|
|
// 修改输入框文本
|
|
changeInputText(text) {
|
|
this.inputText = text;
|
|
},
|
|
// top 打开历史记录
|
|
openDrawer() {
|
|
this.$refs.popup.open();
|
|
},
|
|
// leftDrawer 点击历史记录搜索
|
|
onHistoryItemTap(text) {
|
|
this.inputText = text;
|
|
this.onSend();
|
|
this.$refs.popup.close();
|
|
},
|
|
// leftDrawer 打开历史记录,聊天页面禁止滚动
|
|
changeShow(e) {
|
|
this.show = e;
|
|
},
|
|
// leftDrawer 删除历史记录
|
|
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(),
|
|
});
|
|
},
|
|
// leftDrawer 清除全部历史记录
|
|
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();
|
|
// 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.scrollToBottom();
|
|
this.isReplying = true;
|
|
this.inputText = "";
|
|
this.isLoading = true;
|
|
this.addToHistory(text);
|
|
// 3. 真正等待 AI 回复
|
|
const reply = await getAIResponse({
|
|
message: text,
|
|
});
|
|
if(this.breakReplying) {
|
|
this.breakReplying = false;
|
|
return;
|
|
}
|
|
this.isLoading = false;
|
|
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;
|
|
this.messages.push({
|
|
id: replyId,
|
|
role: "assistant",
|
|
type: "text",
|
|
content,
|
|
displayText: "",
|
|
});
|
|
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 = 0;
|
|
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];
|
|
this.isReplying = false;
|
|
this.breakReplying = false
|
|
this.isLoading = false;
|
|
}
|
|
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
|
|
}
|
|
}, 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;
|
|
}
|
|
|
|
/*
|
|
::v-deep .drawer-scroll {
|
|
padding-top: 44px;
|
|
} */
|
|
</style> |