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.

378 lines
9.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" @continueCreate="continueCreate" :isReplying="isReplying" @refresh="refresh"
@changeShow="changeShow" @changeInputText="changeInputText"/>
</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";
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
};
},
async mounted() {
this.loadChatHistory();
// this.scrollToBottom();
let self = this;
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
},
beforeDestroy() {
// 清理所有打字机定时器
Object.values(this.typewriterTimers).forEach((timer) => {
if (timer) clearInterval(timer);
});
this.typewriterTimers = {};
},
methods: {
// 重新回复
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;
},
// 修改输入框文本
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();
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);
// 3. 真正等待 AI 回复
const reply = await getAIResponse({
message: text,
});
this.replyData = reply;
this.isLoading = false;
if (this.breakReplying) {
this.breakReplying = false;
return;
}
this.replyAction(reply)
},
replyAction(reply, isCreate) {
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: "",
});
}
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;
}
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>