521 lines
15 KiB
Vue
521 lines
15 KiB
Vue
<template>
|
||
<view class="chat-container" @touchend="handleTouchEnd">
|
||
<!-- 顶部的背景 -->
|
||
<ChatTopBgImg class="chat-container-bg"></ChatTopBgImg>
|
||
|
||
<view class="chat-content">
|
||
<!-- 顶部自定义导航栏 -->
|
||
<view class="nav-bar-container" :style="{
|
||
paddingTop: statusBarHeight + 'px',
|
||
}">
|
||
<ChatTopNavBar @openDrawer="openDrawer"></ChatTopNavBar>
|
||
</view>
|
||
|
||
<!-- 消息列表(可滚动区域) -->
|
||
<scroll-view
|
||
:scroll-top="scrollTop"
|
||
scroll-y
|
||
:scroll-with-animation="true"
|
||
class="area-msg-list"
|
||
>
|
||
<!-- welcome栏 -->
|
||
<ChatTopWelcome class="chat-container-top-bannar"
|
||
:initPageImages="mainPageDataModel.initPageImages"
|
||
:welcomeContent="mainPageDataModel.welcomeContent">
|
||
</ChatTopWelcome>
|
||
|
||
<view class="area-msg-list-content" v-for="item in chatMsgList" :key="item.msgId" :id="item.msgId">
|
||
<template v-if="item.msgType === MessageRole.AI">
|
||
<ChatCardAI class="message-item-ai" :text="item.msg">
|
||
<template #content v-if="item.toolCall">
|
||
<QuickBookingComponent v-if="item.toolCall.componentName === CompName.quickBookingCard"/>
|
||
<DiscoveryCardComponent v-else-if="item.toolCall.componentName === CompName.discoveryCard"/>
|
||
<CreateServiceOrder v-else-if="item.toolCall.componentName === CompName.createWorkOrderCard"/>
|
||
</template>
|
||
<template #footer >
|
||
<!-- 这个是底部 -->
|
||
<AttachListComponent v-if="item.question" :question="item.question" @replySent="handleReply"/>
|
||
</template>
|
||
</ChatCardAI>
|
||
</template>
|
||
|
||
<template v-else-if="item.msgType === MessageRole.ME">
|
||
<ChatCardMine class="message-item-mine" :text="item.msg">
|
||
</ChatCardMine>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<ChatCardOther class="message-item-other" :text="item.msg">
|
||
<ChatMoreTips @replySent="handleReply" :itemList="mainPageDataModel.guideWords"/>
|
||
|
||
<ActivityListComponent v-if="mainPageDataModel.activityList.length > 0" :activityList="mainPageDataModel.activityList"/>
|
||
|
||
<RecommendPostsComponent v-if="mainPageDataModel.recommendTheme.length > 0" :recommendThemeList="mainPageDataModel.recommendTheme" />
|
||
</ChatCardOther>
|
||
</template>
|
||
</view>
|
||
|
||
</scroll-view>
|
||
|
||
<!-- 输入框区域 -->
|
||
<view class="footer-area">
|
||
<ChatQuickAccess @replySent="handleReplyInstruct"/>
|
||
<ChatInputArea
|
||
ref="inputAreaRef"
|
||
v-model="inputMessage"
|
||
:holdKeyboard="holdKeyboard"
|
||
:is-session-active="isSessionActive"
|
||
:stop-request="stopRequest"
|
||
@send="sendMessageAction"
|
||
@noHideKeyboard="handleNoHideKeyboard"
|
||
@keyboardShow="handleKeyboardShow"
|
||
@keyboardHide="handleKeyboardHide"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup >
|
||
import { onMounted, nextTick, computed } from 'vue'
|
||
import { ref } from 'vue'
|
||
import { defineEmits } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app';
|
||
import { SCROLL_TO_BOTTOM, RECOMMEND_POSTS_TITLE, SEND_COMMAND_TEXT } from '@/constant/constant'
|
||
import { MessageRole, MessageType, CompName } from '../../model/ChatModel';
|
||
|
||
import ChatTopWelcome from './ChatTopWelcome.vue';
|
||
import ChatTopBgImg from './ChatTopBgImg.vue';
|
||
import ChatTopNavBar from './ChatTopNavBar.vue';
|
||
import ChatCardAI from './ChatCardAI.vue';
|
||
import ChatCardMine from './ChatCardMine.vue';
|
||
import ChatCardOther from './ChatCardOther.vue';
|
||
import ChatQuickAccess from './ChatQuickAccess.vue';
|
||
import ChatMoreTips from './ChatMoreTips.vue';
|
||
import ChatInputArea from './ChatInputArea.vue'
|
||
import CommandWrapper from '@/components/CommandWrapper/index.vue'
|
||
|
||
import QuickBookingComponent from '../module/booking/QuickBookingComponent.vue'
|
||
import DiscoveryCardComponent from '../module/discovery/DiscoveryCardComponent.vue';
|
||
import ActivityListComponent from '../module/banner/ActivityListComponent.vue';
|
||
import RecommendPostsComponent from '../module/recommend/RecommendPostsComponent.vue';
|
||
import AttachListComponent from '../module/attach/AttachListComponent.vue';
|
||
|
||
import CreateServiceOrder from '@/components/CreateServiceOrder/index.vue'
|
||
|
||
import { agentChatStream } from '@/request/api/AgentChatStream';
|
||
import { mainPageData } from '@/request/api/MainPageDataApi';
|
||
import { conversationMsgList, recentConversation } from '@/request/api/ConversationApi';
|
||
|
||
|
||
|
||
/// 导航栏相关
|
||
const statusBarHeight = ref(20);
|
||
/// 输入框组件引用
|
||
const inputAreaRef = ref(null);
|
||
|
||
const timer = ref(null)
|
||
/// focus时,点击页面的时候不收起键盘
|
||
const holdKeyboard = ref(false)
|
||
/// 是否在键盘弹出,点击界面时关闭键盘
|
||
const holdKeyboardFlag = ref(true)
|
||
/// 键盘高度
|
||
const keyboardHeight = ref(0)
|
||
/// 是否显示键盘
|
||
const isKeyboardShow = ref(false)
|
||
|
||
///(控制滚动位置)
|
||
const scrollTop = ref(99999);
|
||
|
||
/// 会话列表
|
||
const chatMsgList = ref([])
|
||
/// 输入口的输入消息
|
||
const inputMessage = ref('')
|
||
/// 加载中
|
||
let currentAIMsgIndex = 0
|
||
|
||
/// 从个渠道获取如二维,没有的时候就返回首页的数据
|
||
const sceneId = ref('')
|
||
/// agentId 首页接口中获取
|
||
const agentId = ref('1')
|
||
/// 会话ID 历史数据接口中获取
|
||
const conversationId = ref('')
|
||
/// 首页的数据
|
||
const mainPageDataModel = ref({})
|
||
|
||
// 会话进行中标志
|
||
const isSessionActive = ref(false);
|
||
// 请求任务引用
|
||
const requestTaskRef = ref(null);
|
||
/// 指令
|
||
let commonType = ''
|
||
|
||
|
||
|
||
|
||
// 打开抽屉
|
||
const emits = defineEmits(['openDrawer'])
|
||
const openDrawer = () => {
|
||
emits('openDrawer')
|
||
console.log('=============打开抽屉')
|
||
}
|
||
|
||
const handleTouchEnd = () => {
|
||
clearTimeout(timer.value)
|
||
timer.value = setTimeout(() => {
|
||
// 键盘弹出时点击界面则关闭键盘
|
||
if (holdKeyboardFlag.value && isKeyboardShow.value) {
|
||
uni.hideKeyboard()
|
||
}
|
||
holdKeyboardFlag.value = true
|
||
}, 100)
|
||
}
|
||
|
||
// 点击输入框、发送按钮时,不收键盘
|
||
const handleNoHideKeyboard = () => {
|
||
holdKeyboardFlag.value = false
|
||
}
|
||
|
||
// 键盘弹起事件
|
||
const handleKeyboardShow = (height) => {
|
||
keyboardHeight.value = height
|
||
isKeyboardShow.value = true
|
||
holdKeyboard.value = true
|
||
// 键盘弹起时调整聊天内容的底部边距并滚动到底部
|
||
setTimeout(() => {
|
||
scrollToBottom()
|
||
}, 150)
|
||
}
|
||
|
||
// 键盘收起事件
|
||
const handleKeyboardHide = () => {
|
||
keyboardHeight.value = 0
|
||
isKeyboardShow.value = false
|
||
holdKeyboard.value = false
|
||
}
|
||
|
||
/// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
nextTick(() => {
|
||
nextTick(() => {
|
||
scrollTop.value += 9999;
|
||
});
|
||
});
|
||
}
|
||
/// 延迟在滚到底
|
||
const setTimeoutScrollToBottom = () => {
|
||
setTimeout(() => {
|
||
scrollToBottom()
|
||
}, 100)
|
||
}
|
||
|
||
/// 发送普通消息
|
||
const handleReply = (text) => {
|
||
sendMessage(text)
|
||
setTimeoutScrollToBottom()
|
||
};
|
||
|
||
/// 是发送指令
|
||
const handleReplyInstruct = (item) => {
|
||
if(item.type === 'MyOrder') {
|
||
/// 订单
|
||
uni.navigateTo({
|
||
url: '/pages/order/list'
|
||
})
|
||
return
|
||
}
|
||
commonType = item.type
|
||
sendMessage(item.title, true)
|
||
setTimeoutScrollToBottom()
|
||
}
|
||
|
||
/// 输入区的发送消息事件
|
||
const sendMessageAction = (inputText) => {
|
||
console.log("输入消息:", inputText)
|
||
if (!inputText.trim()) return;
|
||
handleNoHideKeyboard()
|
||
sendMessage(inputText)
|
||
// 发送消息后保持键盘状态
|
||
if (holdKeyboard.value && inputAreaRef.value) {
|
||
setTimeout(() => {
|
||
inputAreaRef.value.focusInput()
|
||
}, 100)
|
||
}
|
||
setTimeoutScrollToBottom()
|
||
}
|
||
|
||
onLoad(() => {
|
||
uni.getSystemInfo({
|
||
success: (res) => {
|
||
statusBarHeight.value = res.statusBarHeight || 20;
|
||
}
|
||
});
|
||
});
|
||
|
||
onMounted( async() => {
|
||
getMainPageData()
|
||
await loadRecentConversation()
|
||
loadConversationMsgList()
|
||
addNoticeListener()
|
||
})
|
||
|
||
const addNoticeListener = () => {
|
||
uni.$on(SCROLL_TO_BOTTOM, (value) => {
|
||
setTimeout(() => {
|
||
scrollToBottom()
|
||
}, 200)
|
||
})
|
||
|
||
uni.$on(RECOMMEND_POSTS_TITLE, (value) => {
|
||
console.log('RECOMMEND_POSTS_TITLE:', value)
|
||
if(value && value.length > 0) {
|
||
handleReply(value)
|
||
}
|
||
})
|
||
|
||
uni.$on(SEND_COMMAND_TEXT, (value) => {
|
||
console.log('SEND_COMMAND_TEXT:', value)
|
||
if(value && value.length > 0) {
|
||
commonType = 'Command.quickBooking'
|
||
sendMessage(value, true)
|
||
setTimeoutScrollToBottom()
|
||
}
|
||
})
|
||
}
|
||
|
||
/// 获取最近一次的会话id
|
||
const loadRecentConversation = async() => {
|
||
const res = await recentConversation()
|
||
if(res.code === 0) {
|
||
conversationId.value = res.data.conversationId
|
||
}
|
||
}
|
||
|
||
/// 加载历史消息的数据
|
||
let historyCurrentPageNum = 1
|
||
const loadConversationMsgList = async() => {
|
||
const args = { pageNum: historyCurrentPageNum++, pageSize : 10, conversationId: conversationId.value }
|
||
const res = await conversationMsgList(args)
|
||
}
|
||
|
||
/// 获取首页数据
|
||
const getMainPageData = async() => {
|
||
const res = await mainPageData(sceneId.value)
|
||
if(res.code === 0) {
|
||
mainPageDataModel.value = res.data
|
||
agentId.value = res.data.agentId
|
||
initData()
|
||
scrollToBottom()
|
||
}
|
||
}
|
||
|
||
/// 初始化数据 首次数据加载的时候
|
||
const initData = () => {
|
||
const msg = {
|
||
msgId: `msg_${0}`,
|
||
msgType: MessageRole.OTHER,
|
||
msg: '',
|
||
}
|
||
chatMsgList.value.push(msg)
|
||
}
|
||
|
||
|
||
/// 发送消息的参数拼接
|
||
const sendMessage = (message, isInstruct = false) => {
|
||
if (isSessionActive.value) {
|
||
uni.showToast({
|
||
title: '请等待当前回复完成',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
isSessionActive.value = true;
|
||
const newMsg = {
|
||
msgId: `msg_${chatMsgList.value.length}`,
|
||
msgType: MessageRole.ME,
|
||
msg: message,
|
||
msgContent: {
|
||
type: MessageType.TEXT,
|
||
text: message
|
||
}
|
||
}
|
||
chatMsgList.value.push(newMsg)
|
||
inputMessage.value = '';
|
||
sendChat(message, isInstruct)
|
||
console.log("发送的新消息:",JSON.stringify(newMsg))
|
||
}
|
||
|
||
/// 打字机效果实现的变量
|
||
let loadingTimer = null;
|
||
let typeWriterTimer = null;
|
||
let aiMsgBuffer = ''; // 全局缓冲区
|
||
let isTyping = false; // 是否正在打字
|
||
|
||
/// 发送获取AI聊天消息
|
||
const sendChat = (message, isInstruct = false) => {
|
||
const args = {
|
||
conversationId: conversationId.value,
|
||
agentId: agentId.value,
|
||
messageType: isInstruct ? 1 : 0,
|
||
messageContent: isInstruct ? commonType : message
|
||
}
|
||
|
||
// 插入AI消息
|
||
const aiMsg = {
|
||
msgId: `msg_${chatMsgList.value.length}`,
|
||
msgType: MessageRole.AI,
|
||
msg: '加载中.',
|
||
msgContent: {
|
||
type: MessageType.TEXT,
|
||
url: ''
|
||
}
|
||
}
|
||
chatMsgList.value.push(aiMsg)
|
||
const aiMsgIndex = chatMsgList.value.length - 1
|
||
currentAIMsgIndex = aiMsgIndex
|
||
|
||
// 动态加载中动画
|
||
let dotCount = 1;
|
||
loadingTimer && clearInterval(loadingTimer);
|
||
loadingTimer = setInterval(() => {
|
||
dotCount = dotCount % 3 + 1;
|
||
chatMsgList.value[aiMsgIndex].msg = '加载中' + '.'.repeat(dotCount);
|
||
}, 400);
|
||
|
||
aiMsgBuffer = '';
|
||
isTyping = false;
|
||
if (typeWriterTimer) {
|
||
clearTimeout(typeWriterTimer);
|
||
typeWriterTimer = null;
|
||
}
|
||
|
||
// 流式接收内容
|
||
const { promise, requestTask } = agentChatStream(args, (chunk) => {
|
||
console.log('分段内容:', chunk)
|
||
if (chunk && chunk.error) {
|
||
chatMsgList.value[aiMsgIndex].msg = '请求错误,请重试';
|
||
clearInterval(loadingTimer);
|
||
loadingTimer = null;
|
||
isTyping = false;
|
||
typeWriterTimer = null;
|
||
isSessionActive.value = false; // 出错也允许再次发送
|
||
console.error('流式错误:', chunk.message, chunk.detail);
|
||
return;
|
||
}
|
||
|
||
if (chunk && chunk.content) {
|
||
// 收到内容,停止动画
|
||
if (loadingTimer) {
|
||
clearInterval(loadingTimer);
|
||
loadingTimer = null;
|
||
}
|
||
// 把新内容追加到缓冲区
|
||
aiMsgBuffer += chunk.content;
|
||
|
||
// 启动打字机(只启动一次)
|
||
if (!isTyping) {
|
||
isTyping = true;
|
||
chatMsgList.value[aiMsgIndex].msg = '';
|
||
typeWriter();
|
||
}
|
||
}
|
||
if (chunk && chunk.finish) {
|
||
// 结尾处理:确保剩余内容全部输出
|
||
const finishInterval = setInterval(() => {
|
||
if (aiMsgBuffer.length === 0) {
|
||
clearInterval(finishInterval);
|
||
clearInterval(loadingTimer);
|
||
loadingTimer = null;
|
||
isTyping = false;
|
||
typeWriterTimer = null;
|
||
|
||
// 补全:如果消息内容还停留在'加载中.'或为空,则给出友好提示
|
||
const msg = chatMsgList.value[aiMsgIndex].msg;
|
||
console.log('msg:', msg)
|
||
if (!msg || msg === '加载中.' || msg.startsWith('加载中')) {
|
||
chatMsgList.value[aiMsgIndex].msg = '未获取到内容,请重试';
|
||
if(chunk.toolCall) {
|
||
chatMsgList.value[aiMsgIndex].msg = '';
|
||
}
|
||
}
|
||
// 如果有组件
|
||
if(chunk.toolCall) {
|
||
console.log('chunk.toolCall:', chunk.toolCall)
|
||
chatMsgList.value[aiMsgIndex].toolCall = chunk.toolCall
|
||
}
|
||
|
||
// 如果有问题,则设置问题
|
||
if(chunk.question && chunk.question.length > 0) {
|
||
chatMsgList.value[aiMsgIndex].question = chunk.question
|
||
}
|
||
|
||
isSessionActive.value = false;
|
||
scrollToBottom();
|
||
}
|
||
}, 50);
|
||
}
|
||
})
|
||
|
||
// 存储请求任务
|
||
requestTaskRef.value = requestTask;
|
||
|
||
// 可选:处理Promise完成/失败, 已经在回调中处理数据,此处无需再处理
|
||
promise.then(data => {
|
||
console.log('请求完成');
|
||
}).catch(err => {
|
||
isSessionActive.value = false; // 出错也允许再次发送
|
||
console.log('error:', err);
|
||
});
|
||
|
||
// 打字机函数
|
||
function typeWriter() {
|
||
if (aiMsgBuffer.length > 0) {
|
||
chatMsgList.value[aiMsgIndex].msg += aiMsgBuffer[0];
|
||
aiMsgBuffer = aiMsgBuffer.slice(1);
|
||
|
||
nextTick(() => {
|
||
scrollToBottom();
|
||
});
|
||
typeWriterTimer = setTimeout(typeWriter, 30);
|
||
} else {
|
||
// 等待新内容到来,不结束
|
||
typeWriterTimer = setTimeout(typeWriter, 30);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// 停止请求函数
|
||
const stopRequest = () => {
|
||
if (requestTaskRef.value && requestTaskRef.value.abort) {
|
||
// 标记请求已中止,用于过滤后续可能到达的数据
|
||
requestTaskRef.value.isAborted = true;
|
||
// 中止请求
|
||
requestTaskRef.value.abort();
|
||
// 重置状态
|
||
isSessionActive.value = false;
|
||
const msg = chatMsgList.value[currentAIMsgIndex].msg;
|
||
if (!msg || msg === '加载中.' || msg.startsWith('加载中')) {
|
||
chatMsgList.value[currentAIMsgIndex].msg = '已终止请求,请重试';
|
||
}
|
||
// 清除计时器
|
||
if (loadingTimer) {
|
||
clearInterval(loadingTimer);
|
||
loadingTimer = null;
|
||
}
|
||
if (typeWriterTimer) {
|
||
clearTimeout(typeWriterTimer);
|
||
typeWriterTimer = null;
|
||
}
|
||
setTimeoutScrollToBottom()
|
||
// 清空请求引用
|
||
requestTaskRef.value = null;
|
||
}
|
||
}
|
||
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import "styles/ChatMainList.scss";
|
||
</style>
|