You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
489 lines
14 KiB
489 lines
14 KiB
<script setup lang="ts"> |
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
|
import { Button, Modal, Spin, message } from 'ant-design-vue' |
|
import DefaultImage from '@/assets/images/conversation/default_img3.png' |
|
import { SvgIcon } from '@/components/SvgIcon' |
|
import { AppRepositoryFile } from '@/components/AppRepositoryFile' |
|
import { AppContainerBox } from '@/components/AppContainerBox' |
|
import { AppSubMenuTitle } from '@/components/AppSubMenuTitle' |
|
import { AppSubMenuList } from '@/components/AppSubMenuList' |
|
import { AppConversationDefault } from '@/components/AppConversationDefault' |
|
import { AppUserInfo } from '@/components/AppUserInfo' |
|
import { SubMenuActionEnum } from '@/components/AppSubMenuList/index.d' |
|
import type { SubMenuItem } from '@/components/AppSubMenuList/index.d' |
|
|
|
import { AppTextarea } from '@/components/AppTextarea' |
|
import { AppMessage } from '@/components/AppMessage' |
|
import type { MessageItem } from '@/components/AppMessage/index.d' |
|
import type { TopPickItem } from '@/components/AppTopPicks/index.d' |
|
import { AppModelSelect } from '@/components/AppModelSelect' |
|
import type { ModelSelect } from '@/components/AppModelSelect/index.d' |
|
import { useMessageStore } from '@/store/moules/messageStore/index' |
|
import { MenuTypeEnum } from '@/enums/menuEnum' |
|
import { MessageStatusEnum, MessageTypeEnum, ModelTypeEnum } from '@/enums/messageEnum' |
|
import { addMessage, conversationList, conversationToTop, historyMessage, removeMessage, sendRepository, stopMessage, updateMessage } from '@/api/base/message' |
|
import { useMqtt } from '@/hooks/useMqtt' |
|
import { useMessage } from '@/hooks/useMessage' |
|
|
|
const { createConfirm } = useMessage() |
|
|
|
const messageStore = useMessageStore() |
|
|
|
const sendBtnLoading = ref(false) |
|
const subMenuActiveIndex = ref(-1) // 当前会话索引 |
|
const subMenuActionIndex = ref(-1) // 会话操作索引 |
|
const subMenuList = ref<SubMenuItem[]>([]) |
|
const subMenuInputValue = ref<string>('') |
|
const appMessageRef = ref() |
|
|
|
const modelOptions: ModelSelect[] = [ |
|
{ |
|
label: '知识库', |
|
value: ModelTypeEnum.QANYTHING, |
|
}, |
|
] |
|
const modelIndex = ref(0) |
|
|
|
const messageList = computed(() => messageStore.getMessageList) |
|
const messageStatus = computed(() => messageStore.getMessageStatus) |
|
const conversationData = computed(() => messageStore.getConversationData) |
|
const historyMessageParams = ref({ |
|
conversationId: '', |
|
current: 1, |
|
size: 10, |
|
total: 0, |
|
}) |
|
|
|
const leadData = ref({ |
|
title: '你好,我是青鸟AI助手-同聪~', |
|
subTitles: [ |
|
'我可以自由的跟你对话~陪你聊天~帮你想方案~答疑解惑。', |
|
'这里是你的知识库,你可以试着问我知识库里的问题', |
|
], |
|
image: DefaultImage, |
|
}) |
|
const topPickList = ref([ |
|
{ |
|
id: '1', |
|
label: '什么是标识解析?', |
|
}, |
|
{ |
|
id: '2', |
|
label: '如何使用标识进行产品全生命周期优化?', |
|
}, |
|
{ |
|
id: '3', |
|
label: '如何使用标识对供应链进行优化?', |
|
}, |
|
]) |
|
|
|
const conversationDefaultShow = ref(false) |
|
const appMessageShow = ref(true) |
|
const spinning = ref(true) |
|
const fileModelShow = ref(false) |
|
const elIndex = ref(0) // 新分页数据的数量 |
|
|
|
watch( |
|
() => messageStatus.value, |
|
(val) => { |
|
if (val === MessageStatusEnum.END) { |
|
sendBtnLoading.value = false |
|
} |
|
}, |
|
) |
|
|
|
/** |
|
* @description: 点击会话操作项 |
|
*/ |
|
function handlesubMenuActionIndex(type: SubMenuActionEnum, item: SubMenuItem, index: number) { |
|
subMenuActionIndex.value = index |
|
const subMenuActionData = subMenuList.value[index] |
|
if (type === SubMenuActionEnum.EDIT) { |
|
subMenuList.value.forEach((item) => { |
|
item.actionType = SubMenuActionEnum.NOT |
|
}) |
|
subMenuList.value[index].actionType = SubMenuActionEnum.EDIT |
|
subMenuInputValue.value = item.title |
|
} |
|
|
|
if (type === SubMenuActionEnum.DELETE) { |
|
createConfirm({ |
|
title: '删除', |
|
content: `确定要删除${item.title}会话吗?`, |
|
iconType: 'warning', |
|
onOk: () => { |
|
removeMessage(item.id).then(async () => { |
|
message.success('删除成功') |
|
subMenuActiveIndex.value = -1 |
|
conversationDefaultShow.value = true |
|
await getConversationList() |
|
}) |
|
}, |
|
}) |
|
} |
|
|
|
if (type === SubMenuActionEnum.TO_TOP) { |
|
conversationToTop(item.id).then(async () => { |
|
message.success('置顶成功') |
|
await getConversationList() |
|
const index = subMenuList.value.findIndex(item => item.id === subMenuActionData.id) |
|
await handleSubMenuChange(index) |
|
getHistoryMessage() |
|
}) |
|
} |
|
} |
|
|
|
/** |
|
* @description: 切换会话 |
|
*/ |
|
async function handleSubMenuChange(index: number, item?: SubMenuItem) { |
|
if (messageStatus.value !== MessageStatusEnum.END) { |
|
message.warn('请先结束对话') |
|
return |
|
} |
|
if (!subMenuList.value.length) { |
|
return |
|
} |
|
subMenuActiveIndex.value = index |
|
historyMessageParams.value.current = 1 |
|
historyMessageParams.value.total = 0 |
|
messageStore.setConversationData(subMenuList.value[subMenuActiveIndex.value]) |
|
messageStore.setMessageClear() |
|
const modelIdx = modelOptions.findIndex(v => v.value === Number(subMenuList.value[subMenuActiveIndex.value].modelType)) |
|
if (modelIdx === -1) { |
|
message.warn('未找到该对话下的模型信息') |
|
return |
|
} |
|
modelIndex.value = modelIdx |
|
useMqtt().end() |
|
useMqtt().connect() |
|
|
|
// item有的话说明是点击切换,需要重新获取历史消息,否则是不是点击切换调用,不需要获取历史记录 |
|
if (item) { |
|
getHistoryMessage() |
|
} |
|
} |
|
|
|
/** |
|
* @description: 发送消息 |
|
*/ |
|
async function handleSend(value: string) { |
|
sendBtnLoading.value = true |
|
conversationDefaultShow.value = false |
|
|
|
if (subMenuActiveIndex.value === -1) { |
|
try { |
|
spinning.value = true |
|
messageStore.setMessageClear() |
|
await addMessage({ type: MenuTypeEnum.REPOSITORY, title: '新的对话', sort: subMenuList.value.length + 1, modelType: modelOptions[modelIndex.value].value }) |
|
await getConversationList() |
|
await nextTick() |
|
subMenuActiveIndex.value = subMenuList.value.length - 1 |
|
await handleSubMenuChange(subMenuActiveIndex.value) |
|
if (!conversationData.value) { |
|
message.error('对话发送,请稍后重试!') |
|
return |
|
} |
|
sendMessage(conversationData.value.id, value) |
|
spinning.value = false |
|
} |
|
catch (error) { |
|
message.error('新建对话失败,请稍后重试!') |
|
} |
|
} |
|
else { |
|
if (!conversationData.value) { |
|
return |
|
} |
|
sendMessage(conversationData.value.id, value) |
|
} |
|
} |
|
|
|
/** |
|
* @description: 获取会话列表 |
|
*/ |
|
async function getConversationList() { |
|
const res = await conversationList(MenuTypeEnum.REPOSITORY) |
|
res.forEach((item: SubMenuItem) => { |
|
item.actionType = SubMenuActionEnum.NOT |
|
}) |
|
subMenuList.value = res |
|
} |
|
|
|
/** |
|
* @description: 获取历史对话记录 |
|
*/ |
|
async function getHistoryMessage() { |
|
if (!conversationData.value) { |
|
spinning.value = false |
|
conversationDefaultShow.value = true |
|
return |
|
} |
|
spinning.value = true |
|
historyMessageParams.value.conversationId = conversationData.value.id |
|
const res = await historyMessage(historyMessageParams.value) |
|
spinning.value = false |
|
if (!res || !res.records || !res.records.length) { |
|
if (!res.records.length && historyMessageParams.value.total === 0) { |
|
conversationDefaultShow.value = true |
|
} |
|
return |
|
} |
|
|
|
res.records.forEach((item: any) => { |
|
const itemData: MessageItem = { |
|
messageType: item.roleType === MessageTypeEnum.USER ? MessageTypeEnum.USER : MessageTypeEnum.AI, |
|
content: item.messageContent, |
|
time: item.messageTime, |
|
avatar: '', |
|
messageStatus: MessageStatusEnum.END, |
|
} |
|
historyMessageParams.value.total = res.total |
|
messageStore.setMessageUnshiftItem(itemData) |
|
}) |
|
elIndex.value = res.records.length |
|
|
|
conversationDefaultShow.value = false |
|
} |
|
|
|
/** |
|
* @description: 滚动监听 |
|
*/ |
|
async function onScrollTop(scrollTop: number) { |
|
if (scrollTop !== 0) { |
|
return |
|
} |
|
|
|
if (historyMessageParams.value.current * historyMessageParams.value.size >= historyMessageParams.value.total) { |
|
return |
|
} |
|
|
|
if (historyMessageParams.value.current < historyMessageParams.value.total) { |
|
historyMessageParams.value.current++ |
|
await getHistoryMessage() |
|
|
|
// 无缝滚动 |
|
await appMessageRef.value.getElementOffsetTop() |
|
await appMessageRef.value.seamlessScrollToTop() |
|
} |
|
} |
|
|
|
/** |
|
* @description: 发送消息hook |
|
*/ |
|
async function sendMessage(conversationId: string, question: string): Promise<void> { |
|
if (!messageStore.getConversationData) { |
|
return |
|
} |
|
|
|
conversationDefaultShow.value = false |
|
messageStore.setMessageStatus(MessageStatusEnum.LOADING) |
|
messageStore.setMessagePushItem({ |
|
messageType: MessageTypeEnum.USER, |
|
content: question, |
|
time: String(new Date().getTime()), |
|
avatar: '', |
|
}) |
|
messageStore.setMessagePushItem({ |
|
messageType: MessageTypeEnum.AI, |
|
content: '', |
|
time: String(new Date().getTime()), |
|
avatar: '', |
|
messageStatus: MessageStatusEnum.LOADING, |
|
}) |
|
|
|
sendRepository({ |
|
conversationId, |
|
question, |
|
}).catch(() => { |
|
messageStore.getMessageList.splice(-2) |
|
messageStore.setMessageStatus(MessageStatusEnum.END) |
|
}) |
|
} |
|
|
|
/** |
|
* @description: 重新回答 |
|
*/ |
|
function reloadMessage() { |
|
if (!conversationData.value) { |
|
return |
|
} |
|
const question = messageList.value[messageList.value.length - 2]?.content |
|
sendMessage(conversationData.value.id, question) |
|
} |
|
|
|
/** |
|
* @description: 停止回答 |
|
*/ |
|
async function stopMessageFun() { |
|
if (!conversationData.value) { |
|
return |
|
} |
|
await stopMessage({ conversationId: conversationData.value.id }) |
|
} |
|
|
|
/** |
|
* @description: 点击新建会话按钮 |
|
*/ |
|
function handleAddMessage() { |
|
if (messageStatus.value !== MessageStatusEnum.END) { |
|
message.warn('请先结束对话') |
|
return |
|
} |
|
conversationDefaultShow.value = true |
|
subMenuActiveIndex.value = -1 |
|
} |
|
|
|
/** |
|
* @description: 点击默认看板的精选话题 |
|
*/ |
|
function handlePick(_index: number, item: TopPickItem) { |
|
handleSend(item.label) |
|
} |
|
|
|
/** |
|
* @description: gpt模型切换 |
|
*/ |
|
function handleModel(index: number) { |
|
modelIndex.value = index |
|
} |
|
|
|
// 下面是子菜单操作项 |
|
function handleSubMenuInputAffirm(index: number, item: SubMenuItem, inputValue: string) { |
|
updateMessage({ ...item, title: inputValue }).then(() => { |
|
message.success('修改成功') |
|
subMenuList.value[index].title = inputValue |
|
subMenuList.value[index].actionType = SubMenuActionEnum.NOT |
|
}) |
|
} |
|
function handleSubMenuInputClose(index: number) { |
|
subMenuList.value[index].actionType = SubMenuActionEnum.NOT |
|
} |
|
function handleSubMenuInputBlur(index: number, item: SubMenuItem, inputValue: string) { |
|
subMenuActionIndex.value = -1 |
|
handleSubMenuInputAffirm(index, item, inputValue) |
|
} |
|
|
|
onMounted(async () => { |
|
await getConversationList() |
|
if (subMenuList.value.length) { |
|
subMenuActiveIndex.value = 0 |
|
} |
|
await handleSubMenuChange(subMenuActiveIndex.value) |
|
getHistoryMessage() |
|
}) |
|
|
|
onUnmounted(() => { |
|
messageStore.setMessageClear() |
|
messageStore.setConversationData(undefined) |
|
useMqtt().end() |
|
}) |
|
</script> |
|
|
|
<template> |
|
<AppContainerBox> |
|
<template #subMenu> |
|
<!-- 标题 --> |
|
<AppSubMenuTitle></AppSubMenuTitle> |
|
|
|
<!-- 按钮 --> |
|
<div class="px-5 mb-5"> |
|
<Button type="primary" class="w-full" @click="handleAddMessage"> |
|
新建会话 |
|
</Button> |
|
</div> |
|
|
|
<!-- 会话列表 --> |
|
<AppSubMenuList |
|
v-model:input-value="subMenuInputValue" |
|
:list="subMenuList" |
|
:active-index="subMenuActiveIndex" |
|
:action-index="subMenuActionIndex" |
|
@change="handleSubMenuChange" |
|
@handle-action="handlesubMenuActionIndex" |
|
@input-affirm="handleSubMenuInputAffirm" |
|
@input-close="handleSubMenuInputClose" |
|
@input-blur="handleSubMenuInputBlur" |
|
> |
|
</AppSubMenuList> |
|
|
|
<div class="file-box" @click="fileModelShow = true"> |
|
<SvgIcon class="icon" name="file"></SvgIcon> |
|
<span>知识库</span> |
|
</div> |
|
<AppUserInfo /> |
|
</template> |
|
<template #content> |
|
<Spin :spinning="spinning" wrapper-class-name="app-content-spin"> |
|
<AppModelSelect |
|
v-if="conversationDefaultShow" |
|
:active-index="modelIndex" |
|
:options="modelOptions" |
|
@change="handleModel" |
|
> |
|
</AppModelSelect> |
|
<!-- 默认导语 --> |
|
<AppConversationDefault |
|
v-if="conversationDefaultShow" |
|
:is-pick="true" |
|
:lead-data="leadData" |
|
:top-pick-list="topPickList" |
|
height="calc(100% - 148px)" |
|
@handle-pick="handlePick" |
|
> |
|
</AppConversationDefault> |
|
|
|
<div class="h-full flex flex-col"> |
|
<!-- 消息列表 --> |
|
<AppMessage |
|
v-if="!conversationDefaultShow && appMessageShow" |
|
ref="appMessageRef" |
|
:key="MenuTypeEnum.REPOSITORY" |
|
class="pl-27 pr-5" |
|
:el-index="elIndex" |
|
:list="messageList" |
|
@on-scroll-top="onScrollTop" |
|
@reload-message="reloadMessage" |
|
> |
|
</AppMessage> |
|
|
|
<!-- 发送框 --> |
|
<AppTextarea |
|
class="pl-44 pr-24 mt-10" |
|
:btn-loading="sendBtnLoading" |
|
:is-stop="false" |
|
@send="handleSend" |
|
@stop-message="stopMessageFun" |
|
></AppTextarea> |
|
</div> |
|
</Spin> |
|
|
|
<Modal v-model:open="fileModelShow" :footer="false"> |
|
<AppRepositoryFile></AppRepositoryFile> |
|
</Modal> |
|
</template> |
|
</AppContainerBox> |
|
</template> |
|
|
|
<style lang="scss" scoped> |
|
.file-box { |
|
position: absolute; |
|
bottom: 125px; |
|
left: 10px; |
|
width: 55px; |
|
height: 55px; |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
align-items: center; |
|
color: #fff; |
|
font-size: 12px; |
|
background: linear-gradient(131deg, #4670e3 0%, #8ca8f4 100%); |
|
border-radius: 32px; |
|
cursor: pointer; |
|
.icon { |
|
width: 20px; |
|
height: 20px; |
|
} |
|
} |
|
</style>
|
|
|